Build Rust core handshake and Compose UI foundation

This commit is contained in:
2026-07-04 08:59:23 +02:00
parent 68363fb680
commit 89a99582a5
19 changed files with 2212 additions and 253 deletions

56
Cargo.lock generated
View File

@@ -1334,6 +1334,20 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.4.3"
@@ -1343,7 +1357,7 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
@@ -2187,6 +2201,24 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "irpc-iroh"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2342daed629b312f61e57e452b0750a59da162f261b97f260a6354de61d4fb0e"
dependencies = [
"getrandom 0.3.4",
"iroh",
"iroh-base",
"irpc",
"n0-error",
"n0-future",
"postcard",
"serde",
"tokio",
"tracing",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -3291,6 +3323,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
@@ -5025,6 +5063,7 @@ dependencies = [
"iroh",
"iroh-blobs",
"irpc",
"irpc-iroh",
"libc",
"n0-future",
"num_cpus",
@@ -5067,6 +5106,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasite"
version = "0.1.0"
@@ -5588,6 +5636,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wmi"
version = "0.18.4"

View File

@@ -0,0 +1,50 @@
# VniDrop Core Send/Receive Flow
This crate owns the transfer backend. Platform/UI code should pass file handles
or paths into Rust and react to `CoreEvent` updates; it should not move file
bytes through Kotlin memory.
## Send
1. `initialize(app_data_dir, event_sink)` starts the Iroh endpoint, blob
provider, handshake protocol, SQLite repository, and event hub.
2. `share_files(sources, metadata)` validates platform sources, streams each
file into `iroh-blobs`, stores a collection, and returns a VniDrop ticket.
3. New VniDrop shares are `ApprovalRequired` by default. A copied ticket is not
enough to read bytes until the sender approves the receiver endpoint.
4. The sender observes receiver requests through `CoreEvent` entries with
`phase="approval"` and can query them with
`list_receiver_requests(transfer_id)`.
5. `respond_receiver_request(request_id, accepted, reason)` accepts or refuses a
pending request. Accepted requests create a time-limited access session for
the receiver endpoint.
## Receive
1. `receive(ticket, output_dir, receiver_name)` parses and validates the ticket.
2. VniDrop tickets first connect to the handshake ALPN
`/vnidrop/handshake/1` and send `RequestTransfer` metadata to the sender.
3. If approved, the receiver connects to the blobs ALPN, downloads the
collection, and streams files to `output_dir`.
4. If refused, expired, unknown, or cancelled, the receive transfer is marked
`failed` or `cancelled` and emits an error/lifecycle event.
5. Legacy raw `BlobTicket` values do not carry VniDrop metadata, so they bypass
the app approval handshake and use the underlying blob ticket directly.
## Core States And Events
- Transfer statuses: `sharing`, `receiving`, `done`, `failed`, `cancelled`,
`stopped`.
- Main event phases: `endpoint`, `import`, `ticket`, `handshake`, `approval`,
`access`, `transfer`, `download`, `export`, `lifecycle`, `error`.
- Events are sent to `CoreEventSink` immediately and persisted through the event
hub. `list_events` flushes queued persistence before reading SQLite.
- `shutdown()` is idempotent and flushes events before stopping the router.
## Platform File Rules
- Desktop uses normal filesystem paths.
- Android opens SAF/content URIs in Kotlin and passes a borrowed file
descriptor; Rust duplicates the descriptor before streaming.
- iOS starts the security-scoped URL lease in Kotlin and keeps it alive while
Rust streams from the accessible file URL/path.

View File

@@ -17,6 +17,7 @@ futures-lite = "2.6.1"
iroh = "1.0.0"
iroh-blobs = "0.103.0"
irpc = "0.17.0"
irpc-iroh = "0.17.0"
libc = "0.2.186"
n0-future = "0.3.1"
num_cpus = "1.17.0"

View File

@@ -1,11 +1,9 @@
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use std::{collections::HashMap, sync::Arc};
use tokio::sync::RwLock;
use crate::api::TransferAccessMode;
use crate::util::now_ms;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AccessDecision {
@@ -16,7 +14,7 @@ pub(crate) enum AccessDecision {
#[derive(Debug, Default)]
pub(crate) struct AccessPolicy {
modes: RwLock<HashMap<u64, TransferAccessMode>>,
approved_sessions: RwLock<HashSet<(u64, String)>>,
approved_sessions: RwLock<HashMap<(u64, String), ApprovalSession>>,
}
impl AccessPolicy {
@@ -33,14 +31,24 @@ impl AccessPolicy {
self.approved_sessions
.write()
.await
.retain(|(id, _)| *id != transfer_id);
.retain(|(id, _), _| *id != transfer_id);
}
pub(crate) async fn approve_endpoint(&self, transfer_id: u64, endpoint_id: String) {
self.approve_endpoint_until(transfer_id, endpoint_id, None)
.await;
}
pub(crate) async fn approve_endpoint_until(
&self,
transfer_id: u64,
endpoint_id: String,
expires_at: Option<i64>,
) {
self.approved_sessions
.write()
.await
.insert((transfer_id, endpoint_id));
.insert((transfer_id, endpoint_id), ApprovalSession { expires_at });
}
pub(crate) async fn decide(
@@ -48,9 +56,6 @@ impl AccessPolicy {
transfer_id: u64,
endpoint_id: Option<&str>,
) -> AccessDecision {
// This is intentionally only the provider-side gate for milestone one.
// A later handshake can add receiver-request/sender-approval events on
// top without weakening the default public sharing behavior.
match self
.modes
.read()
@@ -66,19 +71,32 @@ impl AccessPolicy {
reason: "missing-endpoint-id",
};
};
if self
.approved_sessions
.read()
.await
.contains(&(transfer_id, endpoint_id.to_string()))
{
AccessDecision::Allow
} else {
let key = (transfer_id, endpoint_id.to_string());
let mut sessions = self.approved_sessions.write().await;
match sessions.get(&key) {
Some(session) if session.is_valid(now_ms()) => AccessDecision::Allow,
Some(_) => {
sessions.remove(&key);
AccessDecision::Deny {
reason: "approval-expired",
}
}
None => AccessDecision::Deny {
reason: "approval-required",
},
}
}
}
}
}
#[derive(Debug, Clone)]
struct ApprovalSession {
expires_at: Option<i64>,
}
impl ApprovalSession {
fn is_valid(&self, now: i64) -> bool {
self.expires_at.is_none_or(|expires_at| expires_at >= now)
}
}

View File

@@ -122,3 +122,18 @@ pub struct TicketInspection {
pub blob_ticket: String,
pub metadata: Option<TransferMetadata>,
}
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct ReceiverRequest {
pub id: String,
pub transfer_id: u64,
pub remote_endpoint_id: String,
pub transfer_name: String,
pub receiver_name: Option<String>,
pub receiver_device_name: Option<String>,
pub app_version: String,
pub status: String,
pub reason: Option<String>,
pub requested_at: i64,
pub responded_at: Option<i64>,
}

View File

@@ -0,0 +1,221 @@
use std::{collections::HashMap, sync::Arc, time::Duration};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::sync::{oneshot, Mutex};
use uuid::Uuid;
use crate::{
access_policy::AccessPolicy,
event_hub::EventHub,
handshake::{HandshakeResponse, RequestTransfer},
repository::{ReceiverRequestInsert, Repository},
util::now_ms,
};
const APPROVAL_TTL_MS: i64 = 10 * 60 * 1000;
const APPROVAL_WAIT_TIMEOUT: Duration = Duration::from_secs(120);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ApprovalDecision {
pub(crate) request_id: String,
pub(crate) accepted: bool,
pub(crate) reason: Option<String>,
}
#[derive(Clone)]
pub(crate) struct ApprovalService {
repository: Repository,
event_hub: Arc<EventHub>,
access_policy: Arc<AccessPolicy>,
pending: Arc<Mutex<HashMap<String, oneshot::Sender<ApprovalDecision>>>>,
}
impl ApprovalService {
pub(crate) fn new(
repository: Repository,
event_hub: Arc<EventHub>,
access_policy: Arc<AccessPolicy>,
) -> Self {
Self {
repository,
event_hub,
access_policy,
pending: Arc::new(Mutex::new(HashMap::new())),
}
}
pub(crate) async fn respond(
&self,
request_id: String,
accepted: bool,
reason: Option<String>,
) -> anyhow::Result<()> {
let sender = self.pending.lock().await.remove(&request_id);
let status = if accepted { "accepted" } else { "refused" };
self.repository
.update_receiver_request_status(&request_id, status, reason.as_deref())
.await?;
if let Some(sender) = sender {
let _ = sender.send(ApprovalDecision {
request_id,
accepted,
reason,
});
}
Ok(())
}
pub(crate) async fn request_transfer(
&self,
remote_endpoint_id: String,
request: RequestTransfer,
) -> HandshakeResponse {
self.event_hub.emit_transfer(
request.transfer_id,
"send",
"handshake",
"transfer-requested",
json!({
"remote_endpoint_id": remote_endpoint_id,
"request": request,
}),
);
match self
.repository
.send_exists(request.transfer_id, &request.transfer_hash)
.await
{
Ok(true) => {
self.wait_for_sender_decision(remote_endpoint_id, request)
.await
}
Ok(false) => {
self.deny(request.transfer_id, remote_endpoint_id, "unknown-transfer")
.await
}
Err(error) => {
tracing::error!(%error, "failed to validate handshake transfer request");
self.deny(request.transfer_id, remote_endpoint_id, "repository-error")
.await
}
}
}
async fn wait_for_sender_decision(
&self,
remote_endpoint_id: String,
request: RequestTransfer,
) -> HandshakeResponse {
let request_id = Uuid::new_v4().to_string();
let (tx, rx) = oneshot::channel();
self.pending.lock().await.insert(request_id.clone(), tx);
let insert_result = self
.repository
.insert_receiver_request(ReceiverRequestInsert {
id: &request_id,
transfer_id: request.transfer_id,
remote_endpoint_id: &remote_endpoint_id,
transfer_name: &request.transfer_name,
receiver_name: request.receiver_name.as_deref(),
receiver_device_name: request.receiver_device_name.as_deref(),
app_version: &request.app_version,
})
.await;
if let Err(error) = insert_result {
self.pending.lock().await.remove(&request_id);
tracing::error!(%error, "failed to persist receiver request");
return self
.deny(request.transfer_id, remote_endpoint_id, "repository-error")
.await;
}
self.event_hub.emit_transfer(
request.transfer_id,
"send",
"approval",
"receiver-requested",
json!({
"request_id": request_id,
"remote_endpoint_id": remote_endpoint_id,
"receiver_name": request.receiver_name,
"receiver_device_name": request.receiver_device_name,
"transfer_name": request.transfer_name,
}),
);
match tokio::time::timeout(APPROVAL_WAIT_TIMEOUT, rx).await {
Ok(Ok(decision)) if decision.accepted => {
let token = Uuid::new_v4().to_string();
let expires_at = now_ms() + APPROVAL_TTL_MS;
self.access_policy
.approve_endpoint_until(
request.transfer_id,
remote_endpoint_id.clone(),
Some(expires_at),
)
.await;
self.event_hub.emit_transfer(
request.transfer_id,
"send",
"approval",
"receiver-accepted",
json!({
"request_id": decision.request_id,
"remote_endpoint_id": remote_endpoint_id,
"expires_at": expires_at,
}),
);
HandshakeResponse::Approved { token, expires_at }
}
Ok(Ok(decision)) => {
self.deny(
request.transfer_id,
remote_endpoint_id,
decision
.reason
.unwrap_or_else(|| "sender-refused".to_string()),
)
.await
}
Ok(Err(_)) | Err(_) => {
self.pending.lock().await.remove(&request_id);
let _ = self
.repository
.update_receiver_request_status(
&request_id,
"expired",
Some("approval timed out"),
)
.await;
self.deny(request.transfer_id, remote_endpoint_id, "approval-timeout")
.await
}
}
}
async fn deny(
&self,
transfer_id: u64,
remote_endpoint_id: String,
reason: impl Into<String>,
) -> HandshakeResponse {
let reason = reason.into();
self.event_hub.emit_transfer(
transfer_id,
"send",
"approval",
"receiver-refused",
json!({
"remote_endpoint_id": remote_endpoint_id,
"reason": reason,
}),
);
HandshakeResponse::Denied { reason }
}
}

View File

@@ -0,0 +1,120 @@
use std::fmt;
use anyhow::Result;
use iroh::{
endpoint::Connection,
protocol::{AcceptError, ProtocolHandler},
Endpoint, EndpointAddr,
};
use irpc::{channel::oneshot, rpc_requests, Client, WithChannels};
use irpc_iroh::{read_request, IrohLazyRemoteConnection};
use serde::{Deserialize, Serialize};
use crate::api::TransferMetadata;
#[derive(Clone)]
pub(crate) struct HandshakeService {
approval: crate::approval::ApprovalService,
}
impl fmt::Debug for HandshakeService {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("HandshakeService")
}
}
impl HandshakeService {
pub(crate) const ALPN: &'static [u8] = b"/vnidrop/handshake/1";
pub(crate) fn new(approval: crate::approval::ApprovalService) -> Self {
Self { approval }
}
pub(crate) fn client(endpoint: Endpoint, addr: EndpointAddr) -> HandshakeClient {
HandshakeClient {
inner: Client::boxed(IrohLazyRemoteConnection::new(
endpoint,
addr,
Self::ALPN.to_vec(),
)),
}
}
async fn handle_request(
&self,
remote_endpoint_id: String,
request: RequestTransfer,
) -> HandshakeResponse {
self.approval
.request_transfer(remote_endpoint_id, request)
.await
}
}
impl ProtocolHandler for HandshakeService {
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
let remote_endpoint_id = connection.remote_id().to_string();
while let Some(message) = read_request::<HandshakeProtocol>(&connection).await? {
match message {
HandshakeMessage::RequestTransfer(message) => {
let WithChannels { inner, tx, .. } = message;
// The receiver-provided name is display data. The trusted
// identity is the endpoint id from the Iroh connection.
let response = self.handle_request(remote_endpoint_id.clone(), inner).await;
let _ = tx.send(response).await;
}
}
}
connection.closed().await;
Ok(())
}
}
#[derive(Debug, Clone)]
pub(crate) struct HandshakeClient {
inner: Client<HandshakeProtocol>,
}
impl HandshakeClient {
pub(crate) async fn request_transfer(
&self,
metadata: &TransferMetadata,
receiver_name: Option<&str>,
) -> Result<HandshakeResponse, irpc::Error> {
self.inner
.rpc(RequestTransfer {
transfer_id: metadata.transfer_id,
transfer_hash: metadata.content_hash.clone(),
transfer_name: metadata.transfer_name.clone(),
receiver_name: receiver_name.map(ToOwned::to_owned),
receiver_device_name: None,
app_version: env!("CARGO_PKG_VERSION").to_string(),
})
.await
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct RequestTransfer {
pub(crate) transfer_id: u64,
pub(crate) transfer_hash: String,
pub(crate) transfer_name: String,
pub(crate) receiver_name: Option<String>,
pub(crate) receiver_device_name: Option<String>,
pub(crate) app_version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) enum HandshakeResponse {
Approved { token: String, expires_at: i64 },
Denied { reason: String },
}
#[rpc_requests(message = HandshakeMessage)]
#[derive(Debug, Serialize, Deserialize)]
enum HandshakeProtocol {
#[rpc(tx=oneshot::Sender<HandshakeResponse>)]
RequestTransfer(RequestTransfer),
}

View File

@@ -1,8 +1,10 @@
mod access_policy;
mod api;
mod approval;
mod error;
mod event_hub;
mod filesystem;
mod handshake;
mod logging;
mod repository;
mod runtime;
@@ -11,8 +13,9 @@ mod ticket;
mod util;
pub use api::{
CoreEvent, CoreEventSink, RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource,
SourceKind, StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata,
CoreEvent, CoreEventSink, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult,
ShareSource, SourceKind, StoredTransfer, TicketInspection, TransferAccessMode,
TransferMetadata,
};
pub use error::VnidropError;
pub use runtime::VnidropCore;

View File

@@ -6,7 +6,7 @@ use sqlx::{
Row, SqlitePool,
};
use crate::api::{CoreEvent, StoredTransfer};
use crate::api::{CoreEvent, ReceiverRequest, StoredTransfer};
use crate::util::now_ms;
const SCHEMA_VERSION: i64 = 1;
@@ -27,6 +27,16 @@ pub(crate) struct TransferUpsert<'a> {
pub(crate) total_size: u64,
}
pub(crate) struct ReceiverRequestInsert<'a> {
pub(crate) id: &'a str,
pub(crate) transfer_id: u64,
pub(crate) remote_endpoint_id: &'a str,
pub(crate) transfer_name: &'a str,
pub(crate) receiver_name: Option<&'a str>,
pub(crate) receiver_device_name: Option<&'a str>,
pub(crate) app_version: &'a str,
}
impl Repository {
pub(crate) async fn open(app_data_dir: &Path) -> Result<Self> {
let db_path = app_data_dir.join("vnidrop.sqlite3");
@@ -87,6 +97,32 @@ impl Repository {
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS receiver_requests (
id TEXT PRIMARY KEY,
transfer_id INTEGER NOT NULL,
remote_endpoint_id TEXT NOT NULL,
transfer_name TEXT NOT NULL,
receiver_name TEXT,
receiver_device_name TEXT,
app_version TEXT NOT NULL,
status TEXT NOT NULL,
reason TEXT,
requested_at INTEGER NOT NULL,
responded_at INTEGER
);
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_receiver_requests_transfer_id ON receiver_requests(transfer_id, requested_at DESC);",
)
.execute(&self.pool)
.await?;
sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))
.execute(&self.pool)
.await?;
@@ -171,6 +207,98 @@ impl Repository {
Ok(())
}
pub(crate) async fn insert_receiver_request(
&self,
request: ReceiverRequestInsert<'_>,
) -> Result<()> {
sqlx::query(
r#"
INSERT INTO receiver_requests (
id, transfer_id, remote_endpoint_id, transfer_name,
receiver_name, receiver_device_name, app_version, status,
reason, requested_at, responded_at
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'requested', NULL, ?8, NULL)
"#,
)
.bind(request.id)
.bind(request.transfer_id as i64)
.bind(request.remote_endpoint_id)
.bind(request.transfer_name)
.bind(request.receiver_name)
.bind(request.receiver_device_name)
.bind(request.app_version)
.bind(now_ms())
.execute(&self.pool)
.await?;
Ok(())
}
pub(crate) async fn update_receiver_request_status(
&self,
id: &str,
status: &str,
reason: Option<&str>,
) -> Result<()> {
let result = sqlx::query(
r#"
UPDATE receiver_requests
SET status = ?1, reason = ?2, responded_at = ?3
WHERE id = ?4
AND status = 'requested'
"#,
)
.bind(status)
.bind(reason)
.bind(now_ms())
.bind(id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
anyhow::bail!("receiver request not found or already handled");
}
Ok(())
}
pub(crate) async fn list_receiver_requests(
&self,
transfer_id: u64,
) -> Result<Vec<ReceiverRequest>> {
let rows = sqlx::query(
r#"
SELECT id, transfer_id, remote_endpoint_id, transfer_name,
receiver_name, receiver_device_name, app_version, status,
reason, requested_at, responded_at
FROM receiver_requests
WHERE transfer_id = ?1
ORDER BY requested_at DESC
"#,
)
.bind(transfer_id as i64)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(row_to_receiver_request).collect())
}
pub(crate) async fn send_exists(&self, transfer_id: u64, content_hash: &str) -> Result<bool> {
let row = sqlx::query(
r#"
SELECT EXISTS(
SELECT 1 FROM transfers
WHERE transfer_id = ?1
AND content_hash = ?2
AND direction = 'send'
AND status = 'sharing'
)
"#,
)
.bind(transfer_id as i64)
.bind(content_hash)
.fetch_one(&self.pool)
.await?;
Ok(row.get::<i64, _>(0) != 0)
}
pub(crate) async fn list_transfers(&self) -> Result<Vec<StoredTransfer>> {
let rows = sqlx::query(
r#"
@@ -243,3 +371,19 @@ fn row_to_event(row: sqlx::sqlite::SqliteRow) -> CoreEvent {
data_json: row.get("data_json"),
}
}
fn row_to_receiver_request(row: sqlx::sqlite::SqliteRow) -> ReceiverRequest {
ReceiverRequest {
id: row.get("id"),
transfer_id: row.get::<i64, _>("transfer_id") as u64,
remote_endpoint_id: row.get("remote_endpoint_id"),
transfer_name: row.get("transfer_name"),
receiver_name: row.get("receiver_name"),
receiver_device_name: row.get("receiver_device_name"),
app_version: row.get("app_version"),
status: row.get("status"),
reason: row.get("reason"),
requested_at: row.get("requested_at"),
responded_at: row.get("responded_at"),
}
}

View File

@@ -32,9 +32,10 @@ use tokio::{
use crate::{
access_policy::{AccessDecision, AccessPolicy},
api::{
CoreEvent, CoreEventSink, RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource,
StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata,
CoreEvent, CoreEventSink, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult,
ShareSource, StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata,
},
approval::ApprovalService,
error::VnidropError,
event_hub::EventHub,
filesystem::{
@@ -42,6 +43,7 @@ use crate::{
read_stream_from_blocking_reader, safe_output_path, wait_for_writer,
write_stream_to_blocking_writer, TransferImport,
},
handshake::{HandshakeResponse, HandshakeService},
logging::init_logging,
repository::{Repository, TransferUpsert},
secret::load_or_create_secret,
@@ -69,7 +71,8 @@ struct CoreInner {
router: Router,
store: FsStore,
repository: Repository,
event_hub: EventHub,
event_hub: Arc<EventHub>,
approval: ApprovalService,
access_policy: Arc<AccessPolicy>,
active_transfers: TokioMutex<HashMap<u64, oneshot::Sender<()>>>,
active_shares: TokioMutex<HashMap<u64, TempTag>>,
@@ -165,6 +168,26 @@ impl VnidropCore {
.map_err(VnidropError::permission)
}
pub fn list_receiver_requests(
&self,
transfer_id: u64,
) -> Result<Vec<ReceiverRequest>, VnidropError> {
self.runtime
.block_on(self.inner.repository.list_receiver_requests(transfer_id))
.map_err(VnidropError::repository)
}
pub fn respond_receiver_request(
&self,
request_id: String,
accepted: bool,
reason: Option<String>,
) -> Result<(), VnidropError> {
self.runtime
.block_on(self.inner.approval.respond(request_id, accepted, reason))
.map_err(VnidropError::permission)
}
pub fn list_transfers(&self) -> Result<Vec<StoredTransfer>, VnidropError> {
self.runtime
.block_on(self.inner.repository.list_transfers())
@@ -215,10 +238,15 @@ impl CoreInner {
// uses them for send progress and for the current approval gate.
let (events, event_rx) = EventSender::channel(128, EventMask::ALL_READONLY);
let blobs = BlobsProtocol::new(&store, Some(events));
let event_hub = Arc::new(EventHub::start(repository.clone(), event_sink));
let access_policy = AccessPolicy::new();
let approval =
ApprovalService::new(repository.clone(), event_hub.clone(), access_policy.clone());
let handshake = HandshakeService::new(approval.clone());
let router = Router::builder(endpoint.clone())
.accept(iroh_blobs::ALPN, blobs)
.accept(HandshakeService::ALPN, handshake)
.spawn();
let event_hub = EventHub::start(repository.clone(), event_sink);
let inner = Arc::new(Self {
endpoint,
@@ -226,7 +254,8 @@ impl CoreInner {
store,
repository,
event_hub,
access_policy: AccessPolicy::new(),
approval,
access_policy,
active_transfers: TokioMutex::new(HashMap::new()),
active_shares: TokioMutex::new(HashMap::new()),
hash_to_transfer: TokioMutex::new(HashMap::new()),
@@ -320,7 +349,7 @@ impl CoreInner {
.await
.insert(import.root_hash.to_string(), metadata.transfer_id);
self.access_policy
.set_mode(metadata.transfer_id, TransferAccessMode::Public)
.set_mode(metadata.transfer_id, TransferAccessMode::ApprovalRequired)
.await;
self.active_shares
.lock()
@@ -464,6 +493,15 @@ impl CoreInner {
tokio::fs::create_dir_all(&output_dir).await?;
self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({}));
if let Some(metadata) = &parsed.metadata {
self.request_transfer_approval(
transfer_id,
parsed.blob_ticket.addr().clone(),
metadata,
receiver_name.as_deref(),
)
.await?;
}
let connection = self
.endpoint
.connect(parsed.blob_ticket.addr().clone(), iroh_blobs::ALPN)
@@ -583,6 +621,49 @@ impl CoreInner {
Ok(())
}
async fn request_transfer_approval(
&self,
local_transfer_id: u64,
addr: iroh::EndpointAddr,
metadata: &TransferMetadata,
receiver_name: Option<&str>,
) -> Result<()> {
self.emit_transfer(
local_transfer_id,
"receive",
"handshake",
"approval-requesting",
json!({
"sender_transfer_id": metadata.transfer_id,
"metadata": metadata,
}),
);
let client = HandshakeService::client(self.endpoint.clone(), addr);
match client
.request_transfer(metadata, receiver_name)
.await
.map_err(|error| anyhow::anyhow!("handshake request failed: {error}"))?
{
HandshakeResponse::Approved { expires_at, .. } => {
self.emit_transfer(
local_transfer_id,
"receive",
"handshake",
"approval-granted",
json!({
"sender_transfer_id": metadata.transfer_id,
"expires_at": expires_at,
}),
);
Ok(())
}
HandshakeResponse::Denied { reason } => {
anyhow::bail!("transfer request was denied by sender: {reason}")
}
}
}
async fn shutdown(&self) {
if self.shutdown_started.swap(true, Ordering::SeqCst) {
return;

View File

@@ -17,7 +17,7 @@ mod tests {
collect_import_files, default_collection_name, path_to_string,
percent_decode_file_url_path, validated_relative_string,
},
repository::Repository,
repository::{ReceiverRequestInsert, Repository},
runtime::VnidropCore,
secret::load_or_create_secret,
ticket::{parse_transfer_ticket, VnidropTicket},
@@ -292,6 +292,42 @@ mod tests {
assert_eq!(events[0].id, "event-1");
}
#[tokio::test]
async fn repository_persists_receiver_requests() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_receiver_request(ReceiverRequestInsert {
id: "request-1",
transfer_id: 77,
remote_endpoint_id: "node-a",
transfer_name: "demo",
receiver_name: Some("receiver"),
receiver_device_name: Some("phone"),
app_version: "0.1.0",
})
.await
.unwrap();
repository
.update_receiver_request_status("request-1", "accepted", None)
.await
.unwrap();
assert!(repository
.update_receiver_request_status("request-1", "refused", Some("late"))
.await
.is_err());
assert!(repository
.update_receiver_request_status("missing", "accepted", None)
.await
.is_err());
let requests = repository.list_receiver_requests(77).await.unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].status, "accepted");
assert_eq!(requests[0].receiver_name.as_deref(), Some("receiver"));
assert!(requests[0].responded_at.is_some());
}
#[tokio::test]
async fn access_policy_requires_approved_endpoint_when_locked() {
let policy = AccessPolicy::new();
@@ -319,6 +355,30 @@ mod tests {
);
}
#[tokio::test]
async fn access_policy_rejects_expired_approval_sessions() {
let policy = AccessPolicy::new();
policy
.set_mode(100, TransferAccessMode::ApprovalRequired)
.await;
policy
.approve_endpoint_until(100, "node-a".to_string(), Some(crate::util::now_ms() - 1))
.await;
assert_eq!(
policy.decide(100, Some("node-a")).await,
AccessDecision::Deny {
reason: "approval-expired"
}
);
assert_eq!(
policy.decide(100, Some("node-a")).await,
AccessDecision::Deny {
reason: "approval-required"
}
);
}
#[test]
fn invalid_receive_ticket_is_typed_and_persisted_as_event() {
let temp = tempfile::tempdir().unwrap();

View File

@@ -1,7 +1,10 @@
use std::sync::{Arc, Mutex};
use std::{
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use vnidrop::{
CoreEvent, CoreEventSink, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode,
CoreEvent, CoreEventSink, ReceiverRequest, ShareMetadataInput, ShareSource, SourceKind,
VnidropCore,
};
@@ -22,6 +25,49 @@ impl RecordingSink {
}
}
fn wait_for_receiver_request(sender: &VnidropCore, transfer_id: u64) -> ReceiverRequest {
let started = Instant::now();
loop {
let requests = sender.list_receiver_requests(transfer_id).unwrap();
if let Some(request) = requests
.into_iter()
.find(|request| request.status == "requested")
{
return request;
}
assert!(
started.elapsed() < Duration::from_secs(15),
"timed out waiting for receiver request"
);
std::thread::sleep(Duration::from_millis(50));
}
}
fn receive_with_response(
sender: &VnidropCore,
transfer_id: u64,
receiver: Arc<VnidropCore>,
ticket: String,
output_dir: String,
receiver_name: Option<String>,
accepted: bool,
) -> Result<(), String> {
let handle = std::thread::spawn(move || {
receiver
.receive(ticket, output_dir, receiver_name)
.map_err(|error| error.to_string())
});
let request = wait_for_receiver_request(sender, transfer_id);
sender
.respond_receiver_request(
request.id,
accepted,
(!accepted).then(|| "sender-refused".to_string()),
)
.unwrap();
handle.join().unwrap()
}
#[test]
fn two_local_cores_transfer_file() {
let sender_dir = tempfile::tempdir().unwrap();
@@ -63,11 +109,14 @@ fn two_local_cores_transfer_file() {
)
.unwrap();
receiver
.receive(
receive_with_response(
&sender,
share.transfer_id,
receiver.clone(),
share.ticket,
output_dir.path().to_string_lossy().to_string(),
Some("receiver".to_string()),
true,
)
.unwrap();
@@ -121,11 +170,14 @@ fn two_local_cores_transfer_directory() {
)
.unwrap();
receiver
.receive(
receive_with_response(
&sender,
share.transfer_id,
receiver.clone(),
share.ticket,
output_dir.path().to_string_lossy().to_string(),
Some("receiver".to_string()),
true,
)
.unwrap();
@@ -189,30 +241,29 @@ fn approval_required_denies_then_allows_receiver() {
},
)
.unwrap();
sender
.set_transfer_access_mode(share.transfer_id, TransferAccessMode::ApprovalRequired)
.unwrap();
assert!(receiver
.receive(
assert!(receive_with_response(
&sender,
share.transfer_id,
receiver.clone(),
share.ticket.clone(),
denied_output.path().to_string_lossy().to_string(),
Some("receiver".to_string()),
false,
)
.is_err());
assert!(sender_sink
.events()
.iter()
.any(|event| event.phase == "access" && event.kind == "request-denied"));
.any(|event| event.phase == "approval" && event.kind == "receiver-refused"));
sender
.approve_endpoint_for_transfer(share.transfer_id, receiver.status().endpoint_id)
.unwrap();
receiver
.receive(
receive_with_response(
&sender,
share.transfer_id,
receiver.clone(),
share.ticket,
allowed_output.path().to_string_lossy().to_string(),
Some("receiver".to_string()),
true,
)
.unwrap();
assert_eq!(

View File

@@ -1,5 +1,6 @@
@file:OptIn(gobley.gradle.InternalGobleyGradleApi::class)
import gobley.gradle.cargo.dsl.appleMobile
import gobley.gradle.rust.targets.RustAndroidTarget
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
@@ -76,6 +77,15 @@ cargo {
packageDirectory = layout.projectDirectory.dir("../crates/vnidrop")
publishJvmArtifacts = true
androidTargetsToBuild.set(setOf(RustAndroidTarget.Arm64))
builds.appleMobile {
variants {
buildTaskProvider.configure {
if (rustTarget.cinteropName == "ios") {
additionalEnvironment.put("IPHONEOS_DEPLOYMENT_TARGET", "16.0.0")
}
}
}
}
}
uniffi {

View File

@@ -1,229 +1,644 @@
package com.vnidrop.app
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Button
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.CoreRepository
import com.vnidrop.app.core.CoreUiState
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.rememberShareFilePicker
import com.vnidrop.app.core.sharePickedFile
import com.vnidrop.app.ui.components.AppCard
import com.vnidrop.app.ui.components.ErrorBanner
import com.vnidrop.app.ui.components.Field
import com.vnidrop.app.ui.components.MetadataRow
import com.vnidrop.app.ui.components.PillTone
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.ProgressRow
import com.vnidrop.app.ui.components.QuietButton
import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.components.StatusPill
import com.vnidrop.app.ui.state.AppDestination
import com.vnidrop.app.ui.state.AppUiState
import com.vnidrop.app.ui.state.ReceiveUiState
import com.vnidrop.app.ui.state.SendUiState
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.activeReceiverRequests
import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.state.friendlyCoreError
import com.vnidrop.app.ui.state.summarizeProgress
import com.vnidrop.app.ui.state.transferSubtitle
import com.vnidrop.app.ui.state.windowClassFor
import com.vnidrop.app.ui.theme.LocalVniDropColors
import com.vnidrop.app.ui.theme.ThemeMode
import com.vnidrop.app.ui.theme.VniDropTheme
import kotlinx.coroutines.launch
import uniffi.vnidrop.CoreEvent
import uniffi.vnidrop.ReceiverRequest
import uniffi.vnidrop.ShareResult
import uniffi.vnidrop.StoredTransfer
import uniffi.vnidrop.TicketInspection
@Composable
@Preview
fun App() {
val platform = remember { getPlatform() }
val repository = remember { CoreRepository() }
val state by repository.state.collectAsState()
val scope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current
val coreState by repository.state.collectAsState()
var appState by remember { mutableStateOf(AppUiState()) }
var appDataDir by remember { mutableStateOf(platform.defaultCoreDataDir) }
var sourcePath by remember { mutableStateOf("") }
var sendState by remember { mutableStateOf(SendUiState()) }
var receiveState by remember { mutableStateOf(ReceiveUiState(outputDirectory = platform.defaultReceiveDir)) }
var selectedFile by remember { mutableStateOf<PickedShareFile?>(null) }
var transferName by remember { mutableStateOf("VniDrop transfer") }
var senderName by remember { mutableStateOf("") }
var ticket by remember { mutableStateOf("") }
var receiveDir by remember { mutableStateOf(platform.defaultReceiveDir) }
var receiverName by remember { mutableStateOf("") }
val shareFilePicker = rememberShareFilePicker(
val scope = rememberCoroutineScope()
val clipboard = LocalClipboardManager.current
val picker = rememberShareFilePicker(
onFilePicked = { file ->
selectedFile = file
sourcePath = file.value
if (transferName.isBlank() || transferName == "VniDrop transfer") {
transferName = file.displayName
}
sendState = sendState.copy(
selectedSource = file.value,
selectedDisplayName = file.displayName,
transferName = if (sendState.transferName == "VniDrop transfer" || sendState.transferName.isBlank()) {
file.displayName
} else {
sendState.transferName
},
)
},
onError = { error -> scope.launch { repository.setError(error) } },
)
MaterialTheme {
LaunchedEffect(coreState.lastShare?.transferId) {
coreState.lastShare?.let { share -> repository.refreshReceiverRequests(share.transferId) }
}
VniDropTheme(mode = appState.themeMode) {
BoxWithConstraints {
val windowClass = windowClassFor(maxWidth.value)
AppFrame(
appState = appState,
coreState = coreState,
windowClass = windowClass,
onDestinationChange = { appState = appState.copy(destination = it) },
) {
when (appState.destination) {
AppDestination.Send -> SendScreen(
coreState = coreState,
sendState = sendState,
onSendStateChange = { sendState = it },
onSelectFile = { picker.pickFile() },
onCreateShare = {
scope.launch {
sendState = sendState.copy(isSharing = true)
val file = selectedFile
if (file != null) {
sharePickedFile(repository, file, sendState.transferName, sendState.senderName)
} else {
repository.sharePath(sendState.selectedSource, sendState.transferName, sendState.senderName)
}
sendState = sendState.copy(isSharing = false)
}
},
onCopyTicket = { ticket -> clipboard.setText(AnnotatedString(ticket)) },
onUseLocally = { ticket ->
receiveState = receiveState.copy(ticket = ticket)
appState = appState.copy(destination = AppDestination.Receive)
},
onRefreshRequests = { transferId -> scope.launch { repository.refreshReceiverRequests(transferId) } },
onRespondRequest = { requestId, accepted ->
scope.launch { repository.respondReceiverRequest(requestId, accepted, reason = if (accepted) null else "sender-refused") }
},
)
AppDestination.Receive -> ReceiveScreen(
coreState = coreState,
receiveState = receiveState,
onReceiveStateChange = { receiveState = it },
onInspect = { scope.launch { repository.inspectTicket(receiveState.ticket) } },
onReceive = {
scope.launch {
receiveState = receiveState.copy(isReceiving = true)
repository.receive(receiveState.ticket, receiveState.outputDirectory, receiveState.receiverName)
receiveState = receiveState.copy(isReceiving = false)
}
},
)
AppDestination.Activity -> ActivityScreen(
coreState = coreState,
onRefresh = {
scope.launch {
repository.refreshTransfers()
repository.refreshEvents()
}
},
onCancel = { transferId -> scope.launch { repository.cancel(transferId) } },
)
AppDestination.Requests -> RequestsScreen(
requests = coreState.receiverRequests,
lastShare = coreState.lastShare,
onRefresh = { transferId -> scope.launch { repository.refreshReceiverRequests(transferId) } },
onRespond = { requestId, accepted ->
scope.launch { repository.respondReceiverRequest(requestId, accepted, reason = if (accepted) null else "sender-refused") }
},
)
AppDestination.Settings -> SettingsScreen(
platformName = platform.name,
appDataDir = appDataDir,
onAppDataDirChange = { appDataDir = it },
coreState = coreState,
themeMode = appState.themeMode,
onThemeModeChange = { appState = appState.copy(themeMode = it) },
diagnosticsVisible = appState.diagnosticsVisible,
onDiagnosticsVisibleChange = { appState = appState.copy(diagnosticsVisible = it) },
onInitialize = { scope.launch { repository.initialize(appDataDir) } },
)
}
if (appState.diagnosticsVisible) {
DiagnosticsPanel(events = coreState.events)
}
}
}
}
}
@Composable
private fun AppFrame(
appState: AppUiState,
coreState: CoreUiState,
windowClass: WindowClass,
onDestinationChange: (AppDestination) -> Unit,
content: @Composable () -> Unit,
) {
val colors = LocalVniDropColors.current
Surface(
modifier = Modifier
.safeContentPadding()
.fillMaxSize()
.background(colors.canvas)
.safeContentPadding(),
color = colors.canvas,
) {
if (windowClass == WindowClass.Compact) {
Column(modifier = Modifier.fillMaxSize()) {
TopBar(coreState = coreState)
Box(modifier = Modifier.weight(1f)) {
ScreenContent(content = content)
}
BottomNav(selected = appState.destination, onDestinationChange = onDestinationChange)
}
} else {
Row(modifier = Modifier.fillMaxSize()) {
SideNav(
selected = appState.destination,
coreState = coreState,
onDestinationChange = onDestinationChange,
)
Box(modifier = Modifier.weight(1f)) {
ScreenContent(content = content)
}
}
}
}
}
@Composable
private fun ScreenContent(content: @Composable () -> Unit) {
val colors = LocalVniDropColors.current
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(colors.canvas)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item {
Text("VniDrop Core", style = MaterialTheme.typography.headlineMedium)
Text(platform.name, style = MaterialTheme.typography.bodySmall)
Text(state.status, style = MaterialTheme.typography.bodyMedium)
}
item {
CoreCard(title = "Initialize") {
OutlinedTextField(
value = appDataDir,
onValueChange = { appDataDir = it },
label = { Text("Core data directory") },
modifier = Modifier.fillMaxWidth(),
)
Button(onClick = {
scope.launch { repository.initialize(appDataDir) }
}) {
Text("Initialize core")
content()
}
}
}
item {
CoreCard(title = "Share file") {
OutlinedTextField(
value = sourcePath,
onValueChange = {
sourcePath = it
selectedFile = null
},
label = { Text("Selected file") },
modifier = Modifier.fillMaxWidth(),
)
Button(onClick = { shareFilePicker.pickFile() }) {
Text("Select file")
@Composable
private fun TopBar(coreState: CoreUiState) {
val colors = LocalVniDropColors.current
Row(
modifier = Modifier
.fillMaxWidth()
.background(colors.sidebar)
.border(BorderStroke(1.dp, colors.border))
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("VniDrop", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
NodeStatus(coreState)
}
OutlinedTextField(
value = transferName,
onValueChange = { transferName = it },
label = { Text("Transfer name") },
modifier = Modifier.fillMaxWidth(),
}
@Composable
private fun SideNav(
selected: AppDestination,
coreState: CoreUiState,
onDestinationChange: (AppDestination) -> Unit,
) {
val colors = LocalVniDropColors.current
Column(
modifier = Modifier
.width(220.dp)
.fillMaxHeight()
.background(colors.sidebar)
.border(BorderStroke(1.dp, colors.border))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("VniDrop", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text("Private file transfer", color = colors.textMuted, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(12.dp))
AppDestination.entries.forEach { destination ->
NavItem(
destination = destination,
selected = destination == selected,
onClick = { onDestinationChange(destination) },
)
OutlinedTextField(
value = senderName,
onValueChange = { senderName = it },
label = { Text("Sender name") },
modifier = Modifier.fillMaxWidth(),
}
Spacer(Modifier.weight(1f))
NodeStatus(coreState)
}
}
@Composable
private fun BottomNav(
selected: AppDestination,
onDestinationChange: (AppDestination) -> Unit,
) {
val colors = LocalVniDropColors.current
Row(
modifier = Modifier
.fillMaxWidth()
.background(colors.sidebar)
.border(BorderStroke(1.dp, colors.border))
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
AppDestination.entries.forEach { destination ->
NavItem(
destination = destination,
selected = destination == selected,
onClick = { onDestinationChange(destination) },
modifier = Modifier.weight(1f),
compact = true,
)
Button(
enabled = state.isInitialized && (selectedFile != null || sourcePath.isNotBlank()),
onClick = {
scope.launch {
val picked = selectedFile
if (picked != null) {
sharePickedFile(repository, picked, transferName, senderName)
}
}
}
@Composable
private fun NavItem(
destination: AppDestination,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
compact: Boolean = false,
) {
val colors = LocalVniDropColors.current
val background = if (selected) colors.surfaceMuted else colors.sidebar
val border = if (selected) colors.brand.copy(alpha = 0.55f) else colors.border.copy(alpha = 0f)
Box(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(background)
.border(1.dp, border, RoundedCornerShape(8.dp))
.selectable(selected = selected, onClick = onClick)
.padding(horizontal = if (compact) 6.dp else 12.dp, vertical = 10.dp),
contentAlignment = Alignment.Center,
) {
Text(
destination.label,
style = if (compact) MaterialTheme.typography.labelMedium else MaterialTheme.typography.bodyMedium,
color = if (selected) colors.textPrimary else colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun NodeStatus(coreState: CoreUiState) {
StatusPill(
label = if (coreState.isInitialized) "Online" else "Offline",
tone = if (coreState.isInitialized) PillTone.Success else PillTone.Neutral,
)
}
@Composable
private fun SendScreen(
coreState: CoreUiState,
sendState: SendUiState,
onSendStateChange: (SendUiState) -> Unit,
onSelectFile: () -> Unit,
onCreateShare: () -> Unit,
onCopyTicket: (String) -> Unit,
onUseLocally: (String) -> Unit,
onRefreshRequests: (ULong) -> Unit,
onRespondRequest: (String, Boolean) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
ScreenHeader("Send", "Create a VniDrop ticket and approve receivers when required.")
ErrorSection(coreState)
AppCard(title = "Source") {
if (sendState.selectedSource.isBlank()) {
EmptyText("Select a file to start a share. The app keeps bytes in Rust and platform file handles.")
} else {
repository.sharePath(sourcePath, transferName, senderName)
MetadataRow("Name", sendState.selectedDisplayName.ifBlank { sendState.selectedSource.substringAfterLast('/') })
MetadataRow("Source", sendState.selectedSource)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
PrimaryButton("Select file", onClick = onSelectFile)
SecondaryButton(
text = "Clear",
onClick = { onSendStateChange(sendState.copy(selectedSource = "", selectedDisplayName = "")) },
enabled = sendState.selectedSource.isNotBlank(),
)
}
}
},
AppCard(title = "Transfer details") {
Field(
value = sendState.transferName,
onValueChange = { onSendStateChange(sendState.copy(transferName = it)) },
label = "Transfer name",
)
Field(
value = sendState.senderName,
onValueChange = { onSendStateChange(sendState.copy(senderName = it)) },
label = "Sender name",
)
PrimaryButton(
text = if (sendState.isSharing) "Creating ticket..." else "Create share ticket",
onClick = onCreateShare,
enabled = coreState.isInitialized && sendState.selectedSource.isNotBlank() && !sendState.isSharing,
)
}
coreState.lastShare?.let { share ->
ShareResultCard(
share = share,
requests = coreState.receiverRequests,
onCopyTicket = onCopyTicket,
onUseLocally = onUseLocally,
onRefreshRequests = onRefreshRequests,
onRespondRequest = onRespondRequest,
)
}
ProgressSection(coreState)
}
}
@Composable
private fun ShareResultCard(
share: ShareResult,
requests: List<ReceiverRequest>,
onCopyTicket: (String) -> Unit,
onUseLocally: (String) -> Unit,
onRefreshRequests: (ULong) -> Unit,
onRespondRequest: (String, Boolean) -> Unit,
) {
Text("Create share ticket")
}
state.lastShare?.let { share ->
Text("Ticket")
SelectionContainer {
Text(share.ticket, style = MaterialTheme.typography.bodySmall)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
clipboardManager.setText(AnnotatedString(share.ticket))
AppCard(title = "Share ticket", trailing = {
StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand)
}) {
Text("Copy ticket")
}
Button(onClick = { ticket = share.ticket }) {
Text("Use locally")
}
}
}
}
}
item {
CoreCard(title = "Receive") {
OutlinedTextField(
value = ticket,
onValueChange = { ticket = it },
label = { Text("Ticket") },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
)
OutlinedTextField(
value = receiveDir,
onValueChange = { receiveDir = it },
label = { Text("Output directory") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = receiverName,
onValueChange = { receiverName = it },
label = { Text("Receiver name") },
modifier = Modifier.fillMaxWidth(),
)
Button(
enabled = state.isInitialized && ticket.isNotBlank(),
onClick = { scope.launch { repository.inspectTicket(ticket) } },
) {
Text("Inspect ticket")
}
Button(
enabled = state.isInitialized && ticket.isNotBlank() && receiveDir.isNotBlank(),
onClick = {
scope.launch { repository.receive(ticket, receiveDir, receiverName) }
},
) {
Text("Receive")
}
state.lastInspection?.let { inspection ->
Text("Kind: ${inspection.kind}")
Text("Blob ticket: ${inspection.blobTicket.take(96)}")
inspection.metadata?.let { metadata ->
Text("${metadata.transferName} | ${metadata.fileCount} files | ${metadata.totalSize} bytes")
}
}
}
}
state.error?.let { error ->
item {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
MetadataRow("Transfer", share.transferName)
MetadataRow("Size", formatBytes(share.totalSize))
SelectionContainer {
Text(
text = error,
modifier = Modifier.padding(12.dp),
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
}
item {
Text("Events", style = MaterialTheme.typography.titleMedium)
}
items(state.events, key = { it.id }) { event ->
Card {
Text(
text = "${event.scope}/${event.direction ?: "-"} ${event.phase}:${event.kind}",
modifier = Modifier.padding(start = 12.dp, top = 10.dp, end = 12.dp),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = event.dataJson,
modifier = Modifier.padding(start = 12.dp, bottom = 10.dp, end = 12.dp),
text = share.ticket,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(LocalVniDropColors.current.surfaceMuted)
.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
PrimaryButton("Copy", onClick = { onCopyTicket(share.ticket) })
SecondaryButton("Use locally", onClick = { onUseLocally(share.ticket) })
SecondaryButton("Refresh", onClick = { onRefreshRequests(share.transferId) })
}
if (requests.isNotEmpty()) {
HorizontalDivider(color = LocalVniDropColors.current.border)
ReceiverRequestList(requests = requests, onRespondRequest = onRespondRequest)
}
}
}
@Composable
private fun ReceiveScreen(
coreState: CoreUiState,
receiveState: ReceiveUiState,
onReceiveStateChange: (ReceiveUiState) -> Unit,
onInspect: () -> Unit,
onReceive: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
ScreenHeader("Receive", "Inspect a ticket, request access, and stream files into the output directory.")
ErrorSection(coreState)
AppCard(title = "Ticket") {
Field(
value = receiveState.ticket,
onValueChange = { onReceiveStateChange(receiveState.copy(ticket = it)) },
label = "Ticket",
minLines = 4,
)
Field(
value = receiveState.outputDirectory,
onValueChange = { onReceiveStateChange(receiveState.copy(outputDirectory = it)) },
label = "Output directory",
)
Field(
value = receiveState.receiverName,
onValueChange = { onReceiveStateChange(receiveState.copy(receiverName = it)) },
label = "Receiver name",
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SecondaryButton(
text = "Inspect ticket",
onClick = onInspect,
enabled = coreState.isInitialized && receiveState.ticket.isNotBlank(),
)
PrimaryButton(
text = if (receiveState.isReceiving) "Receiving..." else "Receive",
onClick = onReceive,
enabled = coreState.isInitialized &&
receiveState.ticket.isNotBlank() &&
receiveState.outputDirectory.isNotBlank() &&
!receiveState.isReceiving,
)
}
}
coreState.lastInspection?.let { TicketInspectionCard(it) }
ProgressSection(coreState)
}
}
@Composable
private fun TicketInspectionCard(inspection: TicketInspection) {
AppCard(title = "Ticket details") {
MetadataRow("Kind", inspection.kind)
inspection.metadata?.let { metadata ->
MetadataRow("Transfer", metadata.transferName)
MetadataRow("Sender", metadata.senderName ?: "Unknown")
MetadataRow("Files", metadata.fileCount.toString())
MetadataRow("Size", formatBytes(metadata.totalSize))
MetadataRow("Hash", metadata.contentHash)
} ?: EmptyText("This ticket does not include VniDrop metadata.")
}
}
@Composable
private fun ActivityScreen(
coreState: CoreUiState,
onRefresh: () -> Unit,
onCancel: (ULong) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
ScreenHeader("Activity", "Follow current and recent transfers from the Rust core.")
ErrorSection(coreState)
AppCard(title = "Transfers", trailing = { SecondaryButton("Refresh", onClick = onRefresh) }) {
if (coreState.transfers.isEmpty()) {
EmptyText("No transfers yet.")
} else {
coreState.transfers.forEach { transfer ->
TransferRow(transfer = transfer, onCancel = onCancel)
}
}
}
ProgressSection(coreState)
}
}
@Composable
private fun TransferRow(transfer: StoredTransfer, onCancel: (ULong) -> Unit) {
val status = displayNameForStatus(transfer.status)
val tone = when (transfer.status.lowercase()) {
"done" -> PillTone.Success
"failed" -> PillTone.Destructive
"cancelled", "stopped" -> PillTone.Warning
"sharing", "receiving" -> PillTone.Brand
else -> PillTone.Neutral
}
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(LocalVniDropColors.current.surfaceRaised)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) {
Text(transfer.transferName ?: "Transfer ${transfer.transferId}", fontWeight = FontWeight.SemiBold)
Text(transferSubtitle(transfer), color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodySmall)
}
StatusPill(status, tone = tone)
}
if (transfer.status == "sharing" || transfer.status == "receiving") {
QuietButton("Cancel", onClick = { onCancel(transfer.transferId) })
}
}
}
@Composable
private fun RequestsScreen(
requests: List<ReceiverRequest>,
lastShare: ShareResult?,
onRefresh: (ULong) -> Unit,
onRespond: (String, Boolean) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
ScreenHeader("Requests", "Approve or refuse receivers for the current share.")
AppCard(title = "Receiver requests", trailing = {
lastShare?.let { SecondaryButton("Refresh", onClick = { onRefresh(it.transferId) }) }
}) {
if (lastShare == null) {
EmptyText("Create a share ticket first.")
} else if (requests.isEmpty()) {
EmptyText("No receiver requests yet.")
} else {
ReceiverRequestList(requests = requests, onRespondRequest = onRespond)
}
}
}
}
@Composable
private fun ReceiverRequestList(
requests: List<ReceiverRequest>,
onRespondRequest: (String, Boolean) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
requests.forEach { request ->
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(LocalVniDropColors.current.surfaceRaised)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) {
Text(request.receiverName ?: "Receiver", fontWeight = FontWeight.SemiBold)
Text(request.remoteEndpointId.take(28), color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodySmall)
}
StatusPill(displayNameForStatus(request.status), tone = if (request.status == "requested") PillTone.Warning else PillTone.Neutral)
}
request.reason?.let { Text(it, color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodySmall) }
if (request.status == "requested") {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SecondaryButton("Refuse", onClick = { onRespondRequest(request.id, false) })
PrimaryButton("Approve", onClick = { onRespondRequest(request.id, true) })
}
}
}
}
@@ -231,18 +646,113 @@ fun App() {
}
@Composable
private fun CoreCard(
title: String,
content: @Composable () -> Unit,
private fun SettingsScreen(
platformName: String,
appDataDir: String,
onAppDataDirChange: (String) -> Unit,
coreState: CoreUiState,
themeMode: ThemeMode,
onThemeModeChange: (ThemeMode) -> Unit,
diagnosticsVisible: Boolean,
onDiagnosticsVisibleChange: (Boolean) -> Unit,
onInitialize: () -> Unit,
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
ScreenHeader("Settings", "Configure the local node and app appearance.")
ErrorSection(coreState)
AppCard(title = "Node") {
MetadataRow("Platform", platformName)
MetadataRow("Status", coreState.status)
Field(value = appDataDir, onValueChange = onAppDataDirChange, label = "Core data directory")
PrimaryButton("Initialize core", onClick = onInitialize)
}
AppCard(title = "Appearance") {
ThemeMode.entries.forEach { mode ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.selectable(selected = themeMode == mode, onClick = { onThemeModeChange(mode) })
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = themeMode == mode, onClick = { onThemeModeChange(mode) })
Text(mode.name)
}
}
}
AppCard(title = "Diagnostics") {
SecondaryButton(
text = if (diagnosticsVisible) "Hide event log" else "Show event log",
onClick = { onDiagnosticsVisibleChange(!diagnosticsVisible) },
)
EmptyText("Diagnostics are intentionally separate from the primary flow so transfer state stays readable.")
}
}
}
@Composable
private fun DiagnosticsPanel(events: List<CoreEvent>) {
AppCard(title = "Event log") {
if (events.isEmpty()) {
EmptyText("No events have been emitted yet.")
} else {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.height(280.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(title, style = MaterialTheme.typography.titleMedium)
HorizontalDivider()
content()
events.forEach { event ->
EventRow(event)
}
}
}
}
}
@Composable
private fun EventRow(event: CoreEvent) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(LocalVniDropColors.current.surfaceRaised)
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text("${event.scope}/${event.direction ?: "-"} ${event.phase}:${event.kind}", style = MaterialTheme.typography.bodySmall)
Text(event.dataJson, color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodySmall)
}
}
@Composable
private fun ProgressSection(coreState: CoreUiState) {
val progress = summarizeProgress(coreState.events)
if (progress.isNotEmpty()) {
AppCard(title = "Progress") {
progress.forEach { item ->
ProgressRow(label = item.label, progress = item.progress)
}
}
}
}
@Composable
private fun ScreenHeader(title: String, subtitle: String) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text(subtitle, color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
private fun ErrorSection(coreState: CoreUiState) {
friendlyCoreError(coreState.error)?.let { ErrorBanner(it) }
}
@Composable
private fun EmptyText(text: String) {
Text(text, color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodyMedium)
}

View File

@@ -10,10 +10,12 @@ import kotlinx.coroutines.withContext
import kotlin.random.Random
import uniffi.vnidrop.CoreEvent
import uniffi.vnidrop.CoreEventSink
import uniffi.vnidrop.ReceiverRequest
import uniffi.vnidrop.ShareMetadataInput
import uniffi.vnidrop.ShareResult
import uniffi.vnidrop.ShareSource
import uniffi.vnidrop.SourceKind
import uniffi.vnidrop.StoredTransfer
import uniffi.vnidrop.TicketInspection
import uniffi.vnidrop.VnidropCore
@@ -21,8 +23,10 @@ data class CoreUiState(
val isInitialized: Boolean = false,
val status: String = "Not initialized",
val events: List<CoreEvent> = emptyList(),
val transfers: List<StoredTransfer> = emptyList(),
val lastShare: ShareResult? = null,
val lastInspection: TicketInspection? = null,
val receiverRequests: List<ReceiverRequest> = emptyList(),
val error: String? = null,
)
@@ -46,6 +50,8 @@ class CoreRepository(
core?.shutdown()
core = VnidropCore.initialize(appDataDir, sink)
refreshStatus()
loadTransfers()
loadEvents()
_state.update { it.copy(isInitialized = true, error = null) }
}
@@ -117,6 +123,7 @@ class CoreRepository(
suspend fun receive(ticket: String, outputDir: String, receiverName: String) = runCore {
requireCore().receive(ticket, outputDir, receiverName.ifBlank { null })
refreshStatus()
loadTransfers()
}
suspend fun receiveIntoSecurityScopedDirectory(
@@ -128,11 +135,34 @@ class CoreRepository(
requireCore().receive(ticket, outputDirectoryUrl, receiverName.ifBlank { null })
}
refreshStatus()
loadTransfers()
}
suspend fun cancel(transferId: ULong) = runCore {
requireCore().cancelTransfer(transferId)
refreshStatus()
loadTransfers()
}
suspend fun refreshReceiverRequests(transferId: ULong) = runCore {
val requests = requireCore().listReceiverRequests(transferId)
_state.update { it.copy(receiverRequests = requests, error = null) }
}
suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null) = runCore {
requireCore().respondReceiverRequest(requestId, accepted, reason)
state.value.lastShare?.let { share ->
val requests = requireCore().listReceiverRequests(share.transferId)
_state.update { it.copy(receiverRequests = requests, error = null) }
}
}
suspend fun refreshTransfers() = runCore {
loadTransfers()
}
suspend fun refreshEvents() = runCore {
loadEvents()
}
suspend fun setError(message: String) {
@@ -166,9 +196,10 @@ class CoreRepository(
senderName = senderName.ifBlank { null },
),
)
_state.update { it.copy(lastShare = result, error = null) }
_state.update { it.copy(lastShare = result, receiverRequests = emptyList(), error = null) }
}
refreshStatus()
loadTransfers()
}
private suspend fun <T> withPlatformPathAccess(
@@ -196,6 +227,16 @@ class CoreRepository(
}
}
private fun loadTransfers() {
val transfers = core?.listTransfers().orEmpty()
_state.update { it.copy(transfers = transfers, error = null) }
}
private fun loadEvents() {
val events = core?.listEvents(null).orEmpty()
_state.update { it.copy(events = events.take(200), error = null) }
}
private fun nextTransferId(): ULong =
Random.nextLong(1, Long.MAX_VALUE).toULong()
}

View File

@@ -0,0 +1,232 @@
package com.vnidrop.app.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.theme.LocalVniDropColors
@Composable
fun AppCard(
title: String,
modifier: Modifier = Modifier,
trailing: @Composable (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val colors = LocalVniDropColors.current
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = colors.surface),
border = BorderStroke(1.dp, colors.border),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
trailing?.invoke()
}
HorizontalDivider(color = colors.border)
content()
}
}
}
@Composable
fun Field(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
minLines: Int = 1,
enabled: Boolean = true,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
modifier = modifier.fillMaxWidth(),
minLines = minLines,
enabled = enabled,
shape = RoundedCornerShape(8.dp),
)
}
@Composable
fun PrimaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
val colors = LocalVniDropColors.current
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 44.dp),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = colors.brand, contentColor = Color.White),
) {
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
fun SecondaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
OutlinedButton(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 44.dp),
shape = RoundedCornerShape(8.dp),
) {
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
fun QuietButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
TextButton(onClick = onClick, enabled = enabled, modifier = modifier.heightIn(min = 40.dp)) {
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
fun StatusPill(
label: String,
modifier: Modifier = Modifier,
tone: PillTone = PillTone.Neutral,
) {
val colors = LocalVniDropColors.current
val color = when (tone) {
PillTone.Neutral -> colors.textMuted
PillTone.Success -> colors.success
PillTone.Warning -> colors.warning
PillTone.Destructive -> colors.destructive
PillTone.Brand -> colors.brand
}
Row(
modifier = modifier
.clip(RoundedCornerShape(999.dp))
.background(color.copy(alpha = 0.12f))
.border(1.dp, color.copy(alpha = 0.32f), RoundedCornerShape(999.dp))
.padding(horizontal = 10.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(
modifier = Modifier
.size(7.dp)
.clip(CircleShape)
.background(color),
)
Text(label, color = color, style = MaterialTheme.typography.labelMedium, maxLines = 1)
}
}
enum class PillTone {
Neutral,
Success,
Warning,
Destructive,
Brand,
}
@Composable
fun ErrorBanner(message: String, modifier: Modifier = Modifier) {
val colors = LocalVniDropColors.current
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = colors.destructive.copy(alpha = 0.14f)),
border = BorderStroke(1.dp, colors.destructive.copy(alpha = 0.28f)),
) {
Text(
text = message,
modifier = Modifier.padding(14.dp),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
)
}
}
@Composable
fun ProgressRow(
label: String,
progress: Float?,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(label, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (progress == null) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
} else {
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
}
}
}
@Composable
fun MetadataRow(label: String, value: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
Text(
text = label,
modifier = Modifier.weight(0.35f),
color = LocalVniDropColors.current.textMuted,
style = MaterialTheme.typography.bodySmall,
)
Text(
text = value,
modifier = Modifier.weight(0.65f),
style = MaterialTheme.typography.bodySmall,
)
}
}

View File

@@ -0,0 +1,147 @@
package com.vnidrop.app.ui.state
import com.vnidrop.app.ui.theme.ThemeMode
import uniffi.vnidrop.CoreEvent
import uniffi.vnidrop.ReceiverRequest
import uniffi.vnidrop.StoredTransfer
import kotlin.math.roundToInt
enum class AppDestination(
val label: String,
) {
Send("Send"),
Receive("Receive"),
Activity("Activity"),
Requests("Requests"),
Settings("Settings"),
}
enum class WindowClass {
Compact,
Medium,
Expanded,
}
fun windowClassFor(widthDp: Float): WindowClass =
when {
widthDp >= 920f -> WindowClass.Expanded
widthDp >= 640f -> WindowClass.Medium
else -> WindowClass.Compact
}
data class AppUiState(
val destination: AppDestination = AppDestination.Send,
val themeMode: ThemeMode = ThemeMode.System,
val diagnosticsVisible: Boolean = false,
)
data class SendUiState(
val selectedSource: String = "",
val selectedDisplayName: String = "",
val transferName: String = "VniDrop transfer",
val senderName: String = "",
val isSharing: Boolean = false,
)
data class ReceiveUiState(
val ticket: String = "",
val outputDirectory: String = "",
val receiverName: String = "",
val isReceiving: Boolean = false,
)
data class TransferProgress(
val transferId: ULong?,
val phase: String,
val label: String,
val progress: Float?,
)
fun displayNameForStatus(status: String): String =
when (status.lowercase()) {
"sharing" -> "Sharing"
"receiving" -> "Receiving"
"done" -> "Done"
"cancelled" -> "Cancelled"
"stopped" -> "Stopped"
"failed" -> "Failed"
else -> status.replaceFirstChar { it.uppercase() }
}
fun activeReceiverRequests(requests: List<ReceiverRequest>): List<ReceiverRequest> =
requests.filter { it.status == "requested" }
fun summarizeProgress(events: List<CoreEvent>): List<TransferProgress> =
events
.filter { event -> event.transferId != null && event.phase in progressPhases }
.distinctBy { event -> "${event.transferId}:${event.phase}" }
.take(6)
.map { event ->
TransferProgress(
transferId = event.transferId,
phase = event.phase,
label = eventLabel(event),
progress = parseProgress(event.dataJson),
)
}
fun transferSubtitle(transfer: StoredTransfer): String {
val pieces = listOfNotNull(
transfer.transferName,
"${transfer.fileCount} file${if (transfer.fileCount == 1UL) "" else "s"}",
formatBytes(transfer.totalSize),
)
return pieces.joinToString(" | ")
}
fun formatBytes(size: ULong): String {
val value = size.toDouble()
val units = listOf("B", "KB", "MB", "GB", "TB")
var scaled = value
var unitIndex = 0
while (scaled >= 1024.0 && unitIndex < units.lastIndex) {
scaled /= 1024.0
unitIndex += 1
}
return if (unitIndex == 0) {
"${size} ${units[unitIndex]}"
} else {
"${(scaled * 10).roundToInt() / 10.0} ${units[unitIndex]}"
}
}
fun friendlyCoreError(raw: String?): String? {
if (raw.isNullOrBlank()) return null
return when {
raw.contains("failed to parse transfer ticket", ignoreCase = true) -> "The ticket could not be read. Check that the full ticket was copied."
raw.contains("permission", ignoreCase = true) || raw.contains("refused", ignoreCase = true) -> "The transfer is waiting for approval or was refused by the sender."
raw.contains("Failed to bind sockets", ignoreCase = true) -> "VniDrop could not open its network sockets on this device."
raw.contains("not found", ignoreCase = true) && raw.contains("libvnidrop", ignoreCase = true) -> "The native VniDrop library is missing from this build."
else -> raw
}
}
private val progressPhases = setOf("import", "ticket", "access", "transfer", "download", "export", "lifecycle")
private fun eventLabel(event: CoreEvent): String {
val direction = event.direction?.replaceFirstChar { it.uppercase() }
val phase = event.phase.replaceFirstChar { it.uppercase() }
val kind = event.kind.replace('-', ' ')
return listOfNotNull(direction, phase, kind).joinToString(" - ")
}
private fun parseProgress(json: String): Float? {
val transferred = findNumber(json, "transferred") ?: findNumber(json, "downloaded") ?: findNumber(json, "written")
val total = findNumber(json, "total") ?: findNumber(json, "total_size")
if (transferred == null || total == null || total <= 0.0) return null
return (transferred / total).toFloat().coerceIn(0f, 1f)
}
private fun findNumber(json: String, key: String): Double? {
val marker = "\"$key\":"
val start = json.indexOf(marker)
if (start < 0) return null
val valueStart = start + marker.length
val valueEnd = json.indexOfAny(charArrayOf(',', '}'), valueStart).takeIf { it >= 0 } ?: json.length
return json.substring(valueStart, valueEnd).trim().toDoubleOrNull()
}

View File

@@ -0,0 +1,158 @@
package com.vnidrop.app.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
enum class ThemeMode {
System,
Light,
Dark,
}
fun resolveDarkTheme(mode: ThemeMode, systemDark: Boolean): Boolean =
when (mode) {
ThemeMode.System -> systemDark
ThemeMode.Light -> false
ThemeMode.Dark -> true
}
@Immutable
data class VniDropColors(
val canvas: Color,
val sidebar: Color,
val surface: Color,
val surfaceRaised: Color,
val surfaceMuted: Color,
val border: Color,
val borderStrong: Color,
val textPrimary: Color,
val textSecondary: Color,
val textMuted: Color,
val brand: Color,
val brandPressed: Color,
val warning: Color,
val destructive: Color,
val success: Color,
)
val LocalVniDropColors = staticCompositionLocalOf { lightVniDropColors }
private val lightVniDropColors = VniDropColors(
canvas = hsl(0f, 0f, 97.3f),
sidebar = hsl(0f, 0f, 98.8f),
surface = hsl(0f, 0f, 100f),
surfaceRaised = hsl(0f, 0f, 98.8f),
surfaceMuted = hsl(0f, 0f, 95.3f),
border = hsl(0f, 0f, 85.9f),
borderStrong = hsl(0f, 0f, 78f),
textPrimary = hsl(0f, 0f, 9f),
textSecondary = hsl(0f, 0f, 32.2f),
textMuted = hsl(0f, 0f, 43.9f),
brand = hsl(153.1f, 60.2f, 52.7f),
brandPressed = hsl(152.9f, 56.1f, 46.5f),
warning = hsl(38.9f, 100f, 57.1f),
destructive = hsl(10.2f, 77.9f, 53.9f),
success = hsl(153.1f, 60.2f, 40f),
)
private val darkVniDropColors = VniDropColors(
canvas = hsl(0f, 0f, 7.1f),
sidebar = hsl(0f, 0f, 9f),
surface = hsl(0f, 0f, 12.2f),
surfaceRaised = hsl(0f, 0f, 14.1f),
surfaceMuted = hsl(0f, 0f, 16.1f),
border = hsl(0f, 0f, 24.3f),
borderStrong = hsl(0f, 0f, 31.4f),
textPrimary = hsl(0f, 0f, 98f),
textSecondary = hsl(0f, 0f, 70.6f),
textMuted = hsl(0f, 0f, 53.7f),
brand = hsl(153.1f, 60.2f, 52.7f),
brandPressed = hsl(152.9f, 56.1f, 46.5f),
warning = hsl(38.9f, 100f, 42.9f),
destructive = hsl(10.2f, 77.9f, 53.9f),
success = hsl(153.1f, 60.2f, 52.7f),
)
private fun materialScheme(tokens: VniDropColors, dark: Boolean): ColorScheme {
val base = if (dark) {
darkColorScheme()
} else {
lightColorScheme()
}
return base.copy(
primary = tokens.brand,
onPrimary = if (dark) Color.Black else Color.White,
primaryContainer = tokens.surfaceMuted,
onPrimaryContainer = tokens.textPrimary,
background = tokens.canvas,
onBackground = tokens.textPrimary,
surface = tokens.surface,
onSurface = tokens.textPrimary,
surfaceVariant = tokens.surfaceMuted,
onSurfaceVariant = tokens.textSecondary,
outline = tokens.border,
outlineVariant = tokens.border,
error = tokens.destructive,
errorContainer = tokens.destructive.copy(alpha = if (dark) 0.22f else 0.16f),
onErrorContainer = tokens.textPrimary,
)
}
@Composable
fun VniDropTheme(
mode: ThemeMode,
content: @Composable () -> Unit,
) {
val dark = resolveDarkTheme(mode, isSystemInDarkTheme())
val tokens = if (dark) darkVniDropColors else lightVniDropColors
androidx.compose.runtime.CompositionLocalProvider(LocalVniDropColors provides tokens) {
MaterialTheme(
colorScheme = materialScheme(tokens, dark),
content = content,
)
}
}
private fun hsl(hue: Float, saturation: Float, lightness: Float): Color {
val h = ((hue % 360f) + 360f) % 360f / 360f
val s = saturation.coerceIn(0f, 100f) / 100f
val l = lightness.coerceIn(0f, 100f) / 100f
if (s == 0f) {
return Color(l, l, l)
}
val q = if (l < 0.5f) l * (1 + s) else l + s - l * s
val p = 2 * l - q
return Color(
red = hueToRgb(p, q, h + 1f / 3f),
green = hueToRgb(p, q, h),
blue = hueToRgb(p, q, h - 1f / 3f),
)
}
private fun hueToRgb(p: Float, q: Float, input: Float): Float {
var t = input
if (t < 0f) t += 1f
if (t > 1f) t -= 1f
return when {
t < 1f / 6f -> p + (q - p) * 6f * t
t < 1f / 2f -> q
t < 2f / 3f -> p + (q - p) * (2f / 3f - t) * 6f
else -> p
}.let { min(1f, max(0f, it)) }
}
fun Color.contrastAgainst(other: Color): Float =
abs(luminanceApproximation() - other.luminanceApproximation())
private fun Color.luminanceApproximation(): Float =
(red * 0.2126f) + (green * 0.7152f) + (blue * 0.0722f)

View File

@@ -0,0 +1,43 @@
package com.vnidrop.app.ui.state
import com.vnidrop.app.ui.theme.ThemeMode
import com.vnidrop.app.ui.theme.resolveDarkTheme
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class AppUiModelsTest {
@Test
fun windowClassUsesCompactMediumExpandedBreakpoints() {
assertEquals(WindowClass.Compact, windowClassFor(390f))
assertEquals(WindowClass.Medium, windowClassFor(700f))
assertEquals(WindowClass.Expanded, windowClassFor(1200f))
}
@Test
fun themeModeResolvesAgainstSystemOnlyWhenRequested() {
assertTrue(resolveDarkTheme(ThemeMode.System, systemDark = true))
assertFalse(resolveDarkTheme(ThemeMode.System, systemDark = false))
assertFalse(resolveDarkTheme(ThemeMode.Light, systemDark = true))
assertTrue(resolveDarkTheme(ThemeMode.Dark, systemDark = false))
}
@Test
fun coreErrorsBecomeStableUserMessages() {
assertEquals(
"The ticket could not be read. Check that the full ticket was copied.",
friendlyCoreError("reason=failed to parse transfer ticket"),
)
assertEquals(
"The transfer is waiting for approval or was refused by the sender.",
friendlyCoreError("permission denied by sender"),
)
}
@Test
fun byteFormattingKeepsTransferCardsReadable() {
assertEquals("58 B", formatBytes(58UL))
assertEquals("1.5 KB", formatBytes(1536UL))
}
}