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", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.4.3" version = "0.4.3"
@@ -1343,7 +1357,7 @@ dependencies = [
"cfg-if", "cfg-if",
"js-sys", "js-sys",
"libc", "libc",
"r-efi", "r-efi 6.0.0",
"rand_core 0.10.1", "rand_core 0.10.1",
"wasm-bindgen", "wasm-bindgen",
] ]
@@ -2187,6 +2201,24 @@ dependencies = [
"syn 2.0.118", "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]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -3291,6 +3323,12 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]] [[package]]
name = "r-efi" name = "r-efi"
version = "6.0.0" version = "6.0.0"
@@ -5025,6 +5063,7 @@ dependencies = [
"iroh", "iroh",
"iroh-blobs", "iroh-blobs",
"irpc", "irpc",
"irpc-iroh",
"libc", "libc",
"n0-future", "n0-future",
"num_cpus", "num_cpus",
@@ -5067,6 +5106,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 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]] [[package]]
name = "wasite" name = "wasite"
version = "0.1.0" version = "0.1.0"
@@ -5588,6 +5636,12 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]] [[package]]
name = "wmi" name = "wmi"
version = "0.18.4" 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 = "1.0.0"
iroh-blobs = "0.103.0" iroh-blobs = "0.103.0"
irpc = "0.17.0" irpc = "0.17.0"
irpc-iroh = "0.17.0"
libc = "0.2.186" libc = "0.2.186"
n0-future = "0.3.1" n0-future = "0.3.1"
num_cpus = "1.17.0" num_cpus = "1.17.0"

View File

@@ -1,11 +1,9 @@
use std::{ use std::{collections::HashMap, sync::Arc};
collections::{HashMap, HashSet},
sync::Arc,
};
use tokio::sync::RwLock; use tokio::sync::RwLock;
use crate::api::TransferAccessMode; use crate::api::TransferAccessMode;
use crate::util::now_ms;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AccessDecision { pub(crate) enum AccessDecision {
@@ -16,7 +14,7 @@ pub(crate) enum AccessDecision {
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub(crate) struct AccessPolicy { pub(crate) struct AccessPolicy {
modes: RwLock<HashMap<u64, TransferAccessMode>>, modes: RwLock<HashMap<u64, TransferAccessMode>>,
approved_sessions: RwLock<HashSet<(u64, String)>>, approved_sessions: RwLock<HashMap<(u64, String), ApprovalSession>>,
} }
impl AccessPolicy { impl AccessPolicy {
@@ -33,14 +31,24 @@ impl AccessPolicy {
self.approved_sessions self.approved_sessions
.write() .write()
.await .await
.retain(|(id, _)| *id != transfer_id); .retain(|(id, _), _| *id != transfer_id);
} }
pub(crate) async fn approve_endpoint(&self, transfer_id: u64, endpoint_id: String) { 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 self.approved_sessions
.write() .write()
.await .await
.insert((transfer_id, endpoint_id)); .insert((transfer_id, endpoint_id), ApprovalSession { expires_at });
} }
pub(crate) async fn decide( pub(crate) async fn decide(
@@ -48,9 +56,6 @@ impl AccessPolicy {
transfer_id: u64, transfer_id: u64,
endpoint_id: Option<&str>, endpoint_id: Option<&str>,
) -> AccessDecision { ) -> 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 match self
.modes .modes
.read() .read()
@@ -66,19 +71,32 @@ impl AccessPolicy {
reason: "missing-endpoint-id", reason: "missing-endpoint-id",
}; };
}; };
if self let key = (transfer_id, endpoint_id.to_string());
.approved_sessions let mut sessions = self.approved_sessions.write().await;
.read() match sessions.get(&key) {
.await Some(session) if session.is_valid(now_ms()) => AccessDecision::Allow,
.contains(&(transfer_id, endpoint_id.to_string())) Some(_) => {
{ sessions.remove(&key);
AccessDecision::Allow
} else {
AccessDecision::Deny { AccessDecision::Deny {
reason: "approval-required", 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 blob_ticket: String,
pub metadata: Option<TransferMetadata>, 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 access_policy;
mod api; mod api;
mod approval;
mod error; mod error;
mod event_hub; mod event_hub;
mod filesystem; mod filesystem;
mod handshake;
mod logging; mod logging;
mod repository; mod repository;
mod runtime; mod runtime;
@@ -11,8 +13,9 @@ mod ticket;
mod util; mod util;
pub use api::{ pub use api::{
CoreEvent, CoreEventSink, RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, CoreEvent, CoreEventSink, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult,
SourceKind, StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata, ShareSource, SourceKind, StoredTransfer, TicketInspection, TransferAccessMode,
TransferMetadata,
}; };
pub use error::VnidropError; pub use error::VnidropError;
pub use runtime::VnidropCore; pub use runtime::VnidropCore;

View File

@@ -6,7 +6,7 @@ use sqlx::{
Row, SqlitePool, Row, SqlitePool,
}; };
use crate::api::{CoreEvent, StoredTransfer}; use crate::api::{CoreEvent, ReceiverRequest, StoredTransfer};
use crate::util::now_ms; use crate::util::now_ms;
const SCHEMA_VERSION: i64 = 1; const SCHEMA_VERSION: i64 = 1;
@@ -27,6 +27,16 @@ pub(crate) struct TransferUpsert<'a> {
pub(crate) total_size: u64, 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 { impl Repository {
pub(crate) async fn open(app_data_dir: &Path) -> Result<Self> { pub(crate) async fn open(app_data_dir: &Path) -> Result<Self> {
let db_path = app_data_dir.join("vnidrop.sqlite3"); let db_path = app_data_dir.join("vnidrop.sqlite3");
@@ -87,6 +97,32 @@ impl Repository {
.execute(&self.pool) .execute(&self.pool)
.await?; .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}")) sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))
.execute(&self.pool) .execute(&self.pool)
.await?; .await?;
@@ -171,6 +207,98 @@ impl Repository {
Ok(()) 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>> { pub(crate) async fn list_transfers(&self) -> Result<Vec<StoredTransfer>> {
let rows = sqlx::query( let rows = sqlx::query(
r#" r#"
@@ -243,3 +371,19 @@ fn row_to_event(row: sqlx::sqlite::SqliteRow) -> CoreEvent {
data_json: row.get("data_json"), 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::{ use crate::{
access_policy::{AccessDecision, AccessPolicy}, access_policy::{AccessDecision, AccessPolicy},
api::{ api::{
CoreEvent, CoreEventSink, RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, CoreEvent, CoreEventSink, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult,
StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata, ShareSource, StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata,
}, },
approval::ApprovalService,
error::VnidropError, error::VnidropError,
event_hub::EventHub, event_hub::EventHub,
filesystem::{ filesystem::{
@@ -42,6 +43,7 @@ use crate::{
read_stream_from_blocking_reader, safe_output_path, wait_for_writer, read_stream_from_blocking_reader, safe_output_path, wait_for_writer,
write_stream_to_blocking_writer, TransferImport, write_stream_to_blocking_writer, TransferImport,
}, },
handshake::{HandshakeResponse, HandshakeService},
logging::init_logging, logging::init_logging,
repository::{Repository, TransferUpsert}, repository::{Repository, TransferUpsert},
secret::load_or_create_secret, secret::load_or_create_secret,
@@ -69,7 +71,8 @@ struct CoreInner {
router: Router, router: Router,
store: FsStore, store: FsStore,
repository: Repository, repository: Repository,
event_hub: EventHub, event_hub: Arc<EventHub>,
approval: ApprovalService,
access_policy: Arc<AccessPolicy>, access_policy: Arc<AccessPolicy>,
active_transfers: TokioMutex<HashMap<u64, oneshot::Sender<()>>>, active_transfers: TokioMutex<HashMap<u64, oneshot::Sender<()>>>,
active_shares: TokioMutex<HashMap<u64, TempTag>>, active_shares: TokioMutex<HashMap<u64, TempTag>>,
@@ -165,6 +168,26 @@ impl VnidropCore {
.map_err(VnidropError::permission) .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> { pub fn list_transfers(&self) -> Result<Vec<StoredTransfer>, VnidropError> {
self.runtime self.runtime
.block_on(self.inner.repository.list_transfers()) .block_on(self.inner.repository.list_transfers())
@@ -215,10 +238,15 @@ impl CoreInner {
// uses them for send progress and for the current approval gate. // uses them for send progress and for the current approval gate.
let (events, event_rx) = EventSender::channel(128, EventMask::ALL_READONLY); let (events, event_rx) = EventSender::channel(128, EventMask::ALL_READONLY);
let blobs = BlobsProtocol::new(&store, Some(events)); 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()) let router = Router::builder(endpoint.clone())
.accept(iroh_blobs::ALPN, blobs) .accept(iroh_blobs::ALPN, blobs)
.accept(HandshakeService::ALPN, handshake)
.spawn(); .spawn();
let event_hub = EventHub::start(repository.clone(), event_sink);
let inner = Arc::new(Self { let inner = Arc::new(Self {
endpoint, endpoint,
@@ -226,7 +254,8 @@ impl CoreInner {
store, store,
repository, repository,
event_hub, event_hub,
access_policy: AccessPolicy::new(), approval,
access_policy,
active_transfers: TokioMutex::new(HashMap::new()), active_transfers: TokioMutex::new(HashMap::new()),
active_shares: TokioMutex::new(HashMap::new()), active_shares: TokioMutex::new(HashMap::new()),
hash_to_transfer: TokioMutex::new(HashMap::new()), hash_to_transfer: TokioMutex::new(HashMap::new()),
@@ -320,7 +349,7 @@ impl CoreInner {
.await .await
.insert(import.root_hash.to_string(), metadata.transfer_id); .insert(import.root_hash.to_string(), metadata.transfer_id);
self.access_policy self.access_policy
.set_mode(metadata.transfer_id, TransferAccessMode::Public) .set_mode(metadata.transfer_id, TransferAccessMode::ApprovalRequired)
.await; .await;
self.active_shares self.active_shares
.lock() .lock()
@@ -464,6 +493,15 @@ impl CoreInner {
tokio::fs::create_dir_all(&output_dir).await?; tokio::fs::create_dir_all(&output_dir).await?;
self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({})); 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 let connection = self
.endpoint .endpoint
.connect(parsed.blob_ticket.addr().clone(), iroh_blobs::ALPN) .connect(parsed.blob_ticket.addr().clone(), iroh_blobs::ALPN)
@@ -583,6 +621,49 @@ impl CoreInner {
Ok(()) 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) { async fn shutdown(&self) {
if self.shutdown_started.swap(true, Ordering::SeqCst) { if self.shutdown_started.swap(true, Ordering::SeqCst) {
return; return;

View File

@@ -17,7 +17,7 @@ mod tests {
collect_import_files, default_collection_name, path_to_string, collect_import_files, default_collection_name, path_to_string,
percent_decode_file_url_path, validated_relative_string, percent_decode_file_url_path, validated_relative_string,
}, },
repository::Repository, repository::{ReceiverRequestInsert, Repository},
runtime::VnidropCore, runtime::VnidropCore,
secret::load_or_create_secret, secret::load_or_create_secret,
ticket::{parse_transfer_ticket, VnidropTicket}, ticket::{parse_transfer_ticket, VnidropTicket},
@@ -292,6 +292,42 @@ mod tests {
assert_eq!(events[0].id, "event-1"); 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] #[tokio::test]
async fn access_policy_requires_approved_endpoint_when_locked() { async fn access_policy_requires_approved_endpoint_when_locked() {
let policy = AccessPolicy::new(); 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] #[test]
fn invalid_receive_ticket_is_typed_and_persisted_as_event() { fn invalid_receive_ticket_is_typed_and_persisted_as_event() {
let temp = tempfile::tempdir().unwrap(); 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::{ use vnidrop::{
CoreEvent, CoreEventSink, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode, CoreEvent, CoreEventSink, ReceiverRequest, ShareMetadataInput, ShareSource, SourceKind,
VnidropCore, 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] #[test]
fn two_local_cores_transfer_file() { fn two_local_cores_transfer_file() {
let sender_dir = tempfile::tempdir().unwrap(); let sender_dir = tempfile::tempdir().unwrap();
@@ -63,11 +109,14 @@ fn two_local_cores_transfer_file() {
) )
.unwrap(); .unwrap();
receiver receive_with_response(
.receive( &sender,
share.transfer_id,
receiver.clone(),
share.ticket, share.ticket,
output_dir.path().to_string_lossy().to_string(), output_dir.path().to_string_lossy().to_string(),
Some("receiver".to_string()), Some("receiver".to_string()),
true,
) )
.unwrap(); .unwrap();
@@ -121,11 +170,14 @@ fn two_local_cores_transfer_directory() {
) )
.unwrap(); .unwrap();
receiver receive_with_response(
.receive( &sender,
share.transfer_id,
receiver.clone(),
share.ticket, share.ticket,
output_dir.path().to_string_lossy().to_string(), output_dir.path().to_string_lossy().to_string(),
Some("receiver".to_string()), Some("receiver".to_string()),
true,
) )
.unwrap(); .unwrap();
@@ -189,30 +241,29 @@ fn approval_required_denies_then_allows_receiver() {
}, },
) )
.unwrap(); .unwrap();
sender assert!(receive_with_response(
.set_transfer_access_mode(share.transfer_id, TransferAccessMode::ApprovalRequired) &sender,
.unwrap(); share.transfer_id,
receiver.clone(),
assert!(receiver
.receive(
share.ticket.clone(), share.ticket.clone(),
denied_output.path().to_string_lossy().to_string(), denied_output.path().to_string_lossy().to_string(),
Some("receiver".to_string()), Some("receiver".to_string()),
false,
) )
.is_err()); .is_err());
assert!(sender_sink assert!(sender_sink
.events() .events()
.iter() .iter()
.any(|event| event.phase == "access" && event.kind == "request-denied")); .any(|event| event.phase == "approval" && event.kind == "receiver-refused"));
sender receive_with_response(
.approve_endpoint_for_transfer(share.transfer_id, receiver.status().endpoint_id) &sender,
.unwrap(); share.transfer_id,
receiver receiver.clone(),
.receive(
share.ticket, share.ticket,
allowed_output.path().to_string_lossy().to_string(), allowed_output.path().to_string_lossy().to_string(),
Some("receiver".to_string()), Some("receiver".to_string()),
true,
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(

View File

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

View File

@@ -1,229 +1,644 @@
package com.vnidrop.app 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Button import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text 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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.CoreRepository import com.vnidrop.app.core.CoreRepository
import com.vnidrop.app.core.CoreUiState
import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.rememberShareFilePicker import com.vnidrop.app.core.rememberShareFilePicker
import com.vnidrop.app.core.sharePickedFile 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 kotlinx.coroutines.launch
import uniffi.vnidrop.CoreEvent
import uniffi.vnidrop.ReceiverRequest
import uniffi.vnidrop.ShareResult
import uniffi.vnidrop.StoredTransfer
import uniffi.vnidrop.TicketInspection
@Composable @Composable
@Preview @Preview
fun App() { fun App() {
val platform = remember { getPlatform() } val platform = remember { getPlatform() }
val repository = remember { CoreRepository() } val repository = remember { CoreRepository() }
val state by repository.state.collectAsState() val coreState by repository.state.collectAsState()
val scope = rememberCoroutineScope() var appState by remember { mutableStateOf(AppUiState()) }
val clipboardManager = LocalClipboardManager.current
var appDataDir by remember { mutableStateOf(platform.defaultCoreDataDir) } 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 selectedFile by remember { mutableStateOf<PickedShareFile?>(null) }
var transferName by remember { mutableStateOf("VniDrop transfer") } val scope = rememberCoroutineScope()
var senderName by remember { mutableStateOf("") } val clipboard = LocalClipboardManager.current
var ticket by remember { mutableStateOf("") } val picker = rememberShareFilePicker(
var receiveDir by remember { mutableStateOf(platform.defaultReceiveDir) }
var receiverName by remember { mutableStateOf("") }
val shareFilePicker = rememberShareFilePicker(
onFilePicked = { file -> onFilePicked = { file ->
selectedFile = file selectedFile = file
sourcePath = file.value sendState = sendState.copy(
if (transferName.isBlank() || transferName == "VniDrop transfer") { selectedSource = file.value,
transferName = file.displayName selectedDisplayName = file.displayName,
} transferName = if (sendState.transferName == "VniDrop transfer" || sendState.transferName.isBlank()) {
file.displayName
} else {
sendState.transferName
},
)
}, },
onError = { error -> scope.launch { repository.setError(error) } }, 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( Surface(
modifier = Modifier modifier = Modifier
.safeContentPadding()
.fillMaxSize() .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( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(colors.canvas)
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
) { ) {
item { item {
Text("VniDrop Core", style = MaterialTheme.typography.headlineMedium) content()
Text(platform.name, style = MaterialTheme.typography.bodySmall)
Text(state.status, style = MaterialTheme.typography.bodyMedium)
} }
}
}
item { @Composable
CoreCard(title = "Initialize") { private fun TopBar(coreState: CoreUiState) {
OutlinedTextField( val colors = LocalVniDropColors.current
value = appDataDir, Row(
onValueChange = { appDataDir = it }, modifier = Modifier
label = { Text("Core data directory") }, .fillMaxWidth()
modifier = Modifier.fillMaxWidth(), .background(colors.sidebar)
) .border(BorderStroke(1.dp, colors.border))
Button(onClick = { .padding(horizontal = 16.dp, vertical = 12.dp),
scope.launch { repository.initialize(appDataDir) } horizontalArrangement = Arrangement.SpaceBetween,
}) { verticalAlignment = Alignment.CenterVertically,
Text("Initialize core") ) {
} Text("VniDrop", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
} NodeStatus(coreState)
} }
}
item { @Composable
CoreCard(title = "Share file") { private fun SideNav(
OutlinedTextField( selected: AppDestination,
value = sourcePath, coreState: CoreUiState,
onValueChange = { onDestinationChange: (AppDestination) -> Unit,
sourcePath = it ) {
selectedFile = null val colors = LocalVniDropColors.current
}, Column(
label = { Text("Selected file") }, modifier = Modifier
modifier = Modifier.fillMaxWidth(), .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) },
) )
Button(onClick = { shareFilePicker.pickFile() }) {
Text("Select file")
} }
OutlinedTextField( Spacer(Modifier.weight(1f))
value = transferName, NodeStatus(coreState)
onValueChange = { transferName = it }, }
label = { Text("Transfer name") }, }
modifier = Modifier.fillMaxWidth(),
@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,
) )
OutlinedTextField( }
value = senderName, }
onValueChange = { senderName = it }, }
label = { Text("Sender name") },
modifier = Modifier.fillMaxWidth(), @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,
) )
Button( }
enabled = state.isInitialized && (selectedFile != null || sourcePath.isNotBlank()), }
onClick = {
scope.launch { @Composable
val picked = selectedFile private fun NodeStatus(coreState: CoreUiState) {
if (picked != null) { StatusPill(
sharePickedFile(repository, picked, transferName, senderName) 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 { } 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(
Text("Create share ticket") 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,
)
} }
state.lastShare?.let { share -> coreState.lastShare?.let { share ->
Text("Ticket") ShareResultCard(
SelectionContainer { share = share,
Text(share.ticket, style = MaterialTheme.typography.bodySmall) requests = coreState.receiverRequests,
onCopyTicket = onCopyTicket,
onUseLocally = onUseLocally,
onRefreshRequests = onRefreshRequests,
onRespondRequest = onRespondRequest,
)
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { ProgressSection(coreState)
Button(onClick = { }
clipboardManager.setText(AnnotatedString(share.ticket)) }
@Composable
private fun ShareResultCard(
share: ShareResult,
requests: List<ReceiverRequest>,
onCopyTicket: (String) -> Unit,
onUseLocally: (String) -> Unit,
onRefreshRequests: (ULong) -> Unit,
onRespondRequest: (String, Boolean) -> Unit,
) {
AppCard(title = "Share ticket", trailing = {
StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand)
}) { }) {
Text("Copy ticket") MetadataRow("Transfer", share.transferName)
} MetadataRow("Size", formatBytes(share.totalSize))
Button(onClick = { ticket = share.ticket }) { SelectionContainer {
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)) {
Text( Text(
text = error, text = share.ticket,
modifier = Modifier.padding(12.dp), modifier = Modifier
color = MaterialTheme.colorScheme.onErrorContainer, .fillMaxWidth()
) .clip(RoundedCornerShape(8.dp))
} .background(LocalVniDropColors.current.surfaceMuted)
} .padding(12.dp),
}
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),
style = MaterialTheme.typography.bodySmall, 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 @Composable
private fun CoreCard( private fun SettingsScreen(
title: String, platformName: String,
content: @Composable () -> Unit, 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)) {
Column( ScreenHeader("Settings", "Configure the local node and app appearance.")
modifier = Modifier.padding(12.dp), ErrorSection(coreState)
verticalArrangement = Arrangement.spacedBy(10.dp), 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,
) { ) {
Text(title, style = MaterialTheme.typography.titleMedium) RadioButton(selected = themeMode == mode, onClick = { onThemeModeChange(mode) })
HorizontalDivider() Text(mode.name)
content() }
}
}
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
.fillMaxWidth()
.height(280.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
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 kotlin.random.Random
import uniffi.vnidrop.CoreEvent import uniffi.vnidrop.CoreEvent
import uniffi.vnidrop.CoreEventSink import uniffi.vnidrop.CoreEventSink
import uniffi.vnidrop.ReceiverRequest
import uniffi.vnidrop.ShareMetadataInput import uniffi.vnidrop.ShareMetadataInput
import uniffi.vnidrop.ShareResult import uniffi.vnidrop.ShareResult
import uniffi.vnidrop.ShareSource import uniffi.vnidrop.ShareSource
import uniffi.vnidrop.SourceKind import uniffi.vnidrop.SourceKind
import uniffi.vnidrop.StoredTransfer
import uniffi.vnidrop.TicketInspection import uniffi.vnidrop.TicketInspection
import uniffi.vnidrop.VnidropCore import uniffi.vnidrop.VnidropCore
@@ -21,8 +23,10 @@ data class CoreUiState(
val isInitialized: Boolean = false, val isInitialized: Boolean = false,
val status: String = "Not initialized", val status: String = "Not initialized",
val events: List<CoreEvent> = emptyList(), val events: List<CoreEvent> = emptyList(),
val transfers: List<StoredTransfer> = emptyList(),
val lastShare: ShareResult? = null, val lastShare: ShareResult? = null,
val lastInspection: TicketInspection? = null, val lastInspection: TicketInspection? = null,
val receiverRequests: List<ReceiverRequest> = emptyList(),
val error: String? = null, val error: String? = null,
) )
@@ -46,6 +50,8 @@ class CoreRepository(
core?.shutdown() core?.shutdown()
core = VnidropCore.initialize(appDataDir, sink) core = VnidropCore.initialize(appDataDir, sink)
refreshStatus() refreshStatus()
loadTransfers()
loadEvents()
_state.update { it.copy(isInitialized = true, error = null) } _state.update { it.copy(isInitialized = true, error = null) }
} }
@@ -117,6 +123,7 @@ class CoreRepository(
suspend fun receive(ticket: String, outputDir: String, receiverName: String) = runCore { suspend fun receive(ticket: String, outputDir: String, receiverName: String) = runCore {
requireCore().receive(ticket, outputDir, receiverName.ifBlank { null }) requireCore().receive(ticket, outputDir, receiverName.ifBlank { null })
refreshStatus() refreshStatus()
loadTransfers()
} }
suspend fun receiveIntoSecurityScopedDirectory( suspend fun receiveIntoSecurityScopedDirectory(
@@ -128,11 +135,34 @@ class CoreRepository(
requireCore().receive(ticket, outputDirectoryUrl, receiverName.ifBlank { null }) requireCore().receive(ticket, outputDirectoryUrl, receiverName.ifBlank { null })
} }
refreshStatus() refreshStatus()
loadTransfers()
} }
suspend fun cancel(transferId: ULong) = runCore { suspend fun cancel(transferId: ULong) = runCore {
requireCore().cancelTransfer(transferId) requireCore().cancelTransfer(transferId)
refreshStatus() 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) { suspend fun setError(message: String) {
@@ -166,9 +196,10 @@ class CoreRepository(
senderName = senderName.ifBlank { null }, senderName = senderName.ifBlank { null },
), ),
) )
_state.update { it.copy(lastShare = result, error = null) } _state.update { it.copy(lastShare = result, receiverRequests = emptyList(), error = null) }
} }
refreshStatus() refreshStatus()
loadTransfers()
} }
private suspend fun <T> withPlatformPathAccess( 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 = private fun nextTransferId(): ULong =
Random.nextLong(1, Long.MAX_VALUE).toULong() 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))
}
}