mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
Build Rust core handshake and Compose UI foundation
This commit is contained in:
56
Cargo.lock
generated
56
Cargo.lock
generated
@@ -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"
|
||||
|
||||
50
crates/vnidrop/CORE_FLOW.md
Normal file
50
crates/vnidrop/CORE_FLOW.md
Normal 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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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-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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
221
crates/vnidrop/src/approval.rs
Normal file
221
crates/vnidrop/src/approval.rs
Normal 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 }
|
||||
}
|
||||
}
|
||||
120
crates/vnidrop/src/handshake.rs
Normal file
120
crates/vnidrop/src/handshake.rs
Normal 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),
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
CoreCard(title = "Share file") {
|
||||
OutlinedTextField(
|
||||
value = sourcePath,
|
||||
onValueChange = {
|
||||
sourcePath = it
|
||||
selectedFile = null
|
||||
},
|
||||
label = { Text("Selected file") },
|
||||
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) },
|
||||
)
|
||||
Button(onClick = { shareFilePicker.pickFile() }) {
|
||||
Text("Select file")
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = transferName,
|
||||
onValueChange = { transferName = it },
|
||||
label = { Text("Transfer 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,
|
||||
)
|
||||
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 {
|
||||
val picked = selectedFile
|
||||
if (picked != null) {
|
||||
sharePickedFile(repository, picked, transferName, senderName)
|
||||
}
|
||||
}
|
||||
|
||||
@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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text("Create share ticket")
|
||||
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,
|
||||
)
|
||||
}
|
||||
state.lastShare?.let { share ->
|
||||
Text("Ticket")
|
||||
SelectionContainer {
|
||||
Text(share.ticket, style = MaterialTheme.typography.bodySmall)
|
||||
coreState.lastShare?.let { share ->
|
||||
ShareResultCard(
|
||||
share = share,
|
||||
requests = coreState.receiverRequests,
|
||||
onCopyTicket = onCopyTicket,
|
||||
onUseLocally = onUseLocally,
|
||||
onRefreshRequests = onRefreshRequests,
|
||||
onRespondRequest = onRespondRequest,
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = {
|
||||
clipboardManager.setText(AnnotatedString(share.ticket))
|
||||
ProgressSection(coreState)
|
||||
}
|
||||
}
|
||||
|
||||
@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")
|
||||
}
|
||||
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(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
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,
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
HorizontalDivider()
|
||||
content()
|
||||
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
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user