diff --git a/.github/workflows/rust-core.yml b/.github/workflows/rust-core.yml new file mode 100644 index 0000000..192b88b --- /dev/null +++ b/.github/workflows/rust-core.yml @@ -0,0 +1,37 @@ +name: Rust core + +on: + pull_request: + paths: + - "Cargo.toml" + - "Cargo.lock" + - "crates/vnidrop/**" + - ".github/workflows/rust-core.yml" + push: + paths: + - "Cargo.toml" + - "Cargo.lock" + - "crates/vnidrop/**" + - ".github/workflows/rust-core.yml" + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Install Rust quality components + run: rustup component add clippy rustfmt + - name: Check formatting + run: cargo fmt --all -- --check + - name: Run strict Clippy + run: cargo clippy --workspace --all-targets -- -D warnings + - name: Run unit and integration tests + run: cargo test --workspace --all-targets + - name: Check documentation + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --workspace --no-deps diff --git a/Cargo.lock b/Cargo.lock index 158af04..1261b9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5056,6 +5056,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-channel", + "blake3", "bytes", "data-encoding", "futures", diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 3866d0e..0930536 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -58,6 +58,6 @@ android { tasks.configureEach { if (name == "mergeDebugJniLibFolders" || name == "mergeDebugNativeLibs") { - dependsOn(":shared:cargoBuildAndroidArm64Debug") + dependsOn(":shared:copyAndroidAndroidArm64Debug") } } diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 542f6f8..2f9818c 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -3,6 +3,9 @@ + + + + + + diff --git a/androidApp/src/main/kotlin/com/vnidrop/app/MainActivity.kt b/androidApp/src/main/kotlin/com/vnidrop/app/MainActivity.kt index c1bef0a..b8513e0 100644 --- a/androidApp/src/main/kotlin/com/vnidrop/app/MainActivity.kt +++ b/androidApp/src/main/kotlin/com/vnidrop/app/MainActivity.kt @@ -4,25 +4,13 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview -import com.vnidrop.app.core.attachAndroidFilePickerContext class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) - attachAndroidFilePickerContext(this) - attachAndroidPlatformContext(this) - setContent { - App() + App(rememberAndroidAppDependencies(this)) } } } - -@Preview -@Composable -fun AppAndroidPreview() { - App() -} diff --git a/androidApp/src/main/res/xml/file_paths.xml b/androidApp/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..db732ae --- /dev/null +++ b/androidApp/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/crates/vnidrop/CORE_FLOW.md b/crates/vnidrop/CORE_FLOW.md index f63a556..96cf5bd 100644 --- a/crates/vnidrop/CORE_FLOW.md +++ b/crates/vnidrop/CORE_FLOW.md @@ -48,3 +48,36 @@ bytes through Kotlin memory. 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. + +## Durability And Filesystem Policy + +- SQLite records have a local UUID in addition to the protocol transfer ID. + Schema-v2 records are migrated in place and keep their tickets and history. +- Imports and receives are recorded before work begins. A process restart marks + interrupted work failed and expires approval requests that no longer have an + in-memory responder. +- Persisted shares are restored only when their root collection is complete and + readable. Missing or corrupt roots fail closed and emit a recovery event. +- Receive destinations use a no-overwrite policy. Rust writes a uniquely named + temporary file in the destination directory, syncs it, and atomically + publishes it with a no-clobber hard link. Failure or cancellation removes the + temporary file. Stale VniDrop temporary files are cleaned on later writes. +- Foreign output sinks receive exactly one terminal callback after a successful + `start_file`: `finish_file` or `abort_file`. + +## Blob Retention Policy + +Stopping a share immediately removes its provider mapping and approval state, +so neither VniDrop nor legacy blob tickets can read it. Physical blob chunks are +not force-deleted at stop time because content-addressed chunks may be shared by +another active collection. They remain eligible for the blob store's garbage +collection. Restart reconciliation never restores a stopped share. + +## Resource Limits + +`CoreLimits` controls source count, collection files and bytes, path and ticket +sizes, metadata, retained events, pending approvals, concurrent transfers, and +the event persistence queue. `initialize` uses conservative defaults; +`initialize_with_limits` supports stricter deployments and tests. Cheap limits +are checked before durable or network work, while remote collection limits are +checked before downloading file content. diff --git a/crates/vnidrop/Cargo.toml b/crates/vnidrop/Cargo.toml index 9925bab..7c1e77c 100644 --- a/crates/vnidrop/Cargo.toml +++ b/crates/vnidrop/Cargo.toml @@ -11,6 +11,7 @@ crate-type = ["cdylib", "staticlib", "rlib"] anyhow = "1.0.102" async-channel = "2.5.0" bytes = "1.11.1" +blake3 = "1.8.3" data-encoding = "2.11.0" futures = "0.3" futures-lite = "2.6.1" diff --git a/crates/vnidrop/src/access_policy.rs b/crates/vnidrop/src/access_policy.rs index 6a7cc56..77ae471 100644 --- a/crates/vnidrop/src/access_policy.rs +++ b/crates/vnidrop/src/access_policy.rs @@ -26,6 +26,13 @@ impl AccessPolicy { self.modes.write().await.insert(transfer_id, mode); } + pub(crate) async fn allows_without_approval(&self, transfer_id: u64) -> bool { + matches!( + self.modes.read().await.get(&transfer_id), + Some(TransferAccessMode::Public) + ) + } + pub(crate) async fn remove_transfer(&self, transfer_id: u64) { self.modes.write().await.remove(&transfer_id); self.approved_sessions @@ -90,6 +97,20 @@ impl AccessPolicy { } } +pub(crate) fn mode_from_storage(value: &str) -> TransferAccessMode { + match value { + "public" => TransferAccessMode::Public, + _ => TransferAccessMode::ApprovalRequired, + } +} + +pub(crate) fn mode_to_storage(mode: &TransferAccessMode) -> &'static str { + match mode { + TransferAccessMode::Public => "public", + TransferAccessMode::ApprovalRequired => "approval_required", + } +} + #[derive(Debug, Clone)] struct ApprovalSession { expires_at: Option, diff --git a/crates/vnidrop/src/api.rs b/crates/vnidrop/src/api.rs index 9f12cb9..f7ad1b7 100644 --- a/crates/vnidrop/src/api.rs +++ b/crates/vnidrop/src/api.rs @@ -1,8 +1,96 @@ +use anyhow::Context; use iroh_blobs::Hash; use serde::{Deserialize, Serialize}; use crate::util::{non_empty, now_ms}; +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct CoreLimits { + pub max_sources: u64, + pub max_collection_files: u64, + pub max_total_bytes: u64, + pub max_path_bytes: u64, + pub max_ticket_bytes: u64, + pub max_metadata_bytes: u64, + pub max_events: u64, + pub max_pending_approvals: u64, + pub max_concurrent_transfers: u64, + pub event_queue_capacity: u64, +} + +impl Default for CoreLimits { + fn default() -> Self { + Self { + max_sources: 128, + max_collection_files: 10_000, + max_total_bytes: 1024 * 1024 * 1024 * 1024, + max_path_bytes: 4_096, + max_ticket_bytes: 1024 * 1024, + max_metadata_bytes: 16 * 1024, + max_events: 500, + max_pending_approvals: 1_024, + max_concurrent_transfers: 8, + event_queue_capacity: 1_024, + } + } +} + +impl CoreLimits { + pub(crate) fn validate(&self) -> anyhow::Result<()> { + let positive = [ + ("max_sources", self.max_sources), + ("max_collection_files", self.max_collection_files), + ("max_total_bytes", self.max_total_bytes), + ("max_path_bytes", self.max_path_bytes), + ("max_ticket_bytes", self.max_ticket_bytes), + ("max_metadata_bytes", self.max_metadata_bytes), + ("max_events", self.max_events), + ("max_pending_approvals", self.max_pending_approvals), + ("max_concurrent_transfers", self.max_concurrent_transfers), + ("event_queue_capacity", self.event_queue_capacity), + ]; + for (name, value) in positive { + if value == 0 { + anyhow::bail!("core limit {name} must be greater than zero"); + } + } + for (name, value) in [ + ("max_pending_approvals", self.max_pending_approvals), + ("max_concurrent_transfers", self.max_concurrent_transfers), + ("event_queue_capacity", self.event_queue_capacity), + ] { + usize::try_from(value) + .with_context(|| format!("core limit {name} exceeds platform capacity"))?; + } + if self.max_total_bytes > i64::MAX as u64 || self.max_events > i64::MAX as u64 { + anyhow::bail!("SQLite-backed limits must fit in a signed 64-bit integer"); + } + Ok(()) + } + + pub(crate) fn validate_metadata_text( + &self, + field: &str, + value: Option<&str>, + ) -> anyhow::Result<()> { + if let Some(value) = value { + if value.len() as u64 > self.max_metadata_bytes { + anyhow::bail!( + "{field} is {} bytes, limit is {}", + value.len(), + self.max_metadata_bytes + ); + } + } + Ok(()) + } +} + +#[uniffi::export] +pub fn default_core_limits() -> CoreLimits { + CoreLimits::default() +} + #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct CoreEvent { pub id: String, @@ -20,6 +108,22 @@ pub trait CoreEventSink: Send + Sync { fn on_event(&self, event: CoreEvent); } +#[uniffi::export(with_foreign)] +pub trait ReceiveOutputSink: Send + Sync { + fn start_file(&self, relative_path: String) -> Result<(), crate::error::VnidropError>; + fn write_chunk( + &self, + relative_path: String, + bytes: Vec, + ) -> Result<(), crate::error::VnidropError>; + fn finish_file(&self, relative_path: String) -> Result<(), crate::error::VnidropError>; + fn abort_file( + &self, + relative_path: String, + reason: String, + ) -> Result<(), crate::error::VnidropError>; +} + #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct RuntimeStatus { pub endpoint_id: String, @@ -49,9 +153,10 @@ pub struct ShareMetadataInput { pub transfer_id: u64, pub transfer_name: Option, pub sender_name: Option, + pub access_mode: TransferAccessMode, } -#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] pub enum TransferAccessMode { Public, ApprovalRequired, @@ -59,7 +164,9 @@ pub enum TransferAccessMode { #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct StoredTransfer { + pub local_id: String, pub transfer_id: u64, + pub peer_id: Option, pub direction: String, pub status: String, pub transfer_name: Option, @@ -67,6 +174,7 @@ pub struct StoredTransfer { pub ticket: Option, pub file_count: u64, pub total_size: u64, + pub access_mode: TransferAccessMode, pub created_at: i64, pub updated_at: i64, } @@ -136,4 +244,5 @@ pub struct ReceiverRequest { pub reason: Option, pub requested_at: i64, pub responded_at: Option, + pub completed_at: Option, } diff --git a/crates/vnidrop/src/approval.rs b/crates/vnidrop/src/approval.rs index d63efac..b8ab720 100644 --- a/crates/vnidrop/src/approval.rs +++ b/crates/vnidrop/src/approval.rs @@ -8,8 +8,9 @@ use uuid::Uuid; use crate::{ access_policy::AccessPolicy, event_hub::EventHub, - handshake::{HandshakeResponse, RequestTransfer}, + handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, RequestTransfer}, repository::{ReceiverRequestInsert, Repository}, + transfer_state::ReceiverRequestStatus, util::now_ms, }; @@ -29,19 +30,60 @@ pub(crate) struct ApprovalService { event_hub: Arc, access_policy: Arc, pending: Arc>>>, + max_pending: usize, + max_metadata_bytes: u64, } impl ApprovalService { + pub(crate) async fn complete_delivery( + &self, + remote_endpoint_id: String, + receipt: DeliveryReceipt, + ) -> DeliveryReceiptResponse { + let token_hash = receipt_token_hash(&receipt.token); + match self + .repository + .complete_receiver_delivery( + &receipt.request_id, + receipt.transfer_id, + &remote_endpoint_id, + &token_hash, + ) + .await + { + Ok(()) => { + self.event_hub.emit_transfer( + receipt.transfer_id, + "send", + "delivery", + "receiver-completed", + json!({ "request_id": receipt.request_id, "remote_endpoint_id": remote_endpoint_id }), + ); + DeliveryReceiptResponse::Recorded + } + Err(error) => { + tracing::warn!(%error, "rejected receiver delivery receipt"); + DeliveryReceiptResponse::Rejected { + reason: "invalid-receipt".to_string(), + } + } + } + } + pub(crate) fn new( repository: Repository, event_hub: Arc, access_policy: Arc, + max_pending: usize, + max_metadata_bytes: u64, ) -> Self { Self { repository, event_hub, access_policy, pending: Arc::new(Mutex::new(HashMap::new())), + max_pending, + max_metadata_bytes, } } @@ -52,7 +94,11 @@ impl ApprovalService { reason: Option, ) -> anyhow::Result<()> { let sender = self.pending.lock().await.remove(&request_id); - let status = if accepted { "accepted" } else { "refused" }; + let status = if accepted { + ReceiverRequestStatus::Accepted + } else { + ReceiverRequestStatus::Refused + }; self.repository .update_receiver_request_status(&request_id, status, reason.as_deref()) .await?; @@ -73,6 +119,25 @@ impl ApprovalService { remote_endpoint_id: String, request: RequestTransfer, ) -> HandshakeResponse { + let metadata_values = [ + request.transfer_hash.as_str(), + request.transfer_name.as_str(), + request.receiver_name.as_deref().unwrap_or_default(), + request.receiver_device_name.as_deref().unwrap_or_default(), + request.app_version.as_str(), + ]; + if metadata_values + .iter() + .any(|value| value.len() as u64 > self.max_metadata_bytes) + { + return self + .deny( + request.transfer_id, + remote_endpoint_id, + "metadata-too-large", + ) + .await; + } self.event_hub.emit_transfer( request.transfer_id, "send", @@ -90,8 +155,17 @@ impl ApprovalService { .await { Ok(true) => { - self.wait_for_sender_decision(remote_endpoint_id, request) + if self + .access_policy + .allows_without_approval(request.transfer_id) .await + { + self.allow_without_sender_decision(remote_endpoint_id, request) + .await + } else { + self.wait_for_sender_decision(remote_endpoint_id, request) + .await + } } Ok(false) => { self.deny(request.transfer_id, remote_endpoint_id, "unknown-transfer") @@ -105,6 +179,64 @@ impl ApprovalService { } } + async fn allow_without_sender_decision( + &self, + remote_endpoint_id: String, + request: RequestTransfer, + ) -> HandshakeResponse { + let request_id = Uuid::new_v4().to_string(); + if 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 + .is_err() + || self + .repository + .update_receiver_request_status(&request_id, ReceiverRequestStatus::Accepted, None) + .await + .is_err() + { + return self + .deny(request.transfer_id, remote_endpoint_id, "repository-error") + .await; + } + let token = Uuid::new_v4().to_string(); + if self + .repository + .set_receiver_receipt_token(&request_id, &receipt_token_hash(&token)) + .await + .is_err() + { + return self + .deny(request.transfer_id, remote_endpoint_id, "repository-error") + .await; + } + let expires_at = now_ms() + APPROVAL_TTL_MS; + self.event_hub.emit_transfer( + request.transfer_id, + "send", + "access", + "receiver-auto-approved", + json!({ + "remote_endpoint_id": remote_endpoint_id, + "expires_at": expires_at, + }), + ); + HandshakeResponse::Approved { + request_id, + token, + expires_at, + } + } + async fn wait_for_sender_decision( &self, remote_endpoint_id: String, @@ -112,7 +244,19 @@ impl ApprovalService { ) -> HandshakeResponse { let request_id = Uuid::new_v4().to_string(); let (tx, rx) = oneshot::channel(); - self.pending.lock().await.insert(request_id.clone(), tx); + let mut pending = self.pending.lock().await; + if pending.len() >= self.max_pending { + drop(pending); + return self + .deny( + request.transfer_id, + remote_endpoint_id, + "too-many-pending-approvals", + ) + .await; + } + pending.insert(request_id.clone(), tx); + drop(pending); let insert_result = self .repository @@ -171,7 +315,21 @@ impl ApprovalService { "expires_at": expires_at, }), ); - HandshakeResponse::Approved { token, expires_at } + if let Err(error) = self + .repository + .set_receiver_receipt_token(&decision.request_id, &receipt_token_hash(&token)) + .await + { + tracing::error!(%error, "failed to attach delivery receipt token"); + return HandshakeResponse::Denied { + reason: "repository-error".to_string(), + }; + } + HandshakeResponse::Approved { + request_id: decision.request_id, + token, + expires_at, + } } Ok(Ok(decision)) => { self.deny( @@ -189,7 +347,7 @@ impl ApprovalService { .repository .update_receiver_request_status( &request_id, - "expired", + ReceiverRequestStatus::Expired, Some("approval timed out"), ) .await; @@ -219,3 +377,7 @@ impl ApprovalService { HandshakeResponse::Denied { reason } } } + +fn receipt_token_hash(token: &str) -> String { + blake3::hash(token.as_bytes()).to_hex().to_string() +} diff --git a/crates/vnidrop/src/event_hub.rs b/crates/vnidrop/src/event_hub.rs index 22ec182..a728762 100644 --- a/crates/vnidrop/src/event_hub.rs +++ b/crates/vnidrop/src/event_hub.rs @@ -9,9 +9,100 @@ use tokio::{ use crate::{ api::{CoreEvent, CoreEventSink}, repository::Repository, + transfer_state::TransferDirection, util::now_ms, }; +#[derive(Debug, Clone, Copy)] +enum EventScope { + Endpoint, + Transfer, +} + +impl EventScope { + const fn as_str(self) -> &'static str { + match self { + Self::Endpoint => "endpoint", + Self::Transfer => "transfer", + } + } +} + +#[derive(Debug, Clone, Copy)] +enum EventPhase { + Startup, + Recovery, + Shutdown, + Provider, + Error, + Import, + Ticket, + Lifecycle, + Network, + Download, + Export, + Access, + Handshake, + Approval, + Transfer, +} + +impl EventPhase { + fn parse(value: &str) -> Option { + match value { + "startup" => Some(Self::Startup), + "recovery" => Some(Self::Recovery), + "shutdown" => Some(Self::Shutdown), + "provider" => Some(Self::Provider), + "error" => Some(Self::Error), + "import" => Some(Self::Import), + "ticket" => Some(Self::Ticket), + "lifecycle" => Some(Self::Lifecycle), + "network" => Some(Self::Network), + "download" => Some(Self::Download), + "export" => Some(Self::Export), + "access" => Some(Self::Access), + "handshake" => Some(Self::Handshake), + "approval" => Some(Self::Approval), + "transfer" => Some(Self::Transfer), + _ => None, + } + } + + const fn as_str(self) -> &'static str { + match self { + Self::Startup => "startup", + Self::Recovery => "recovery", + Self::Shutdown => "shutdown", + Self::Provider => "provider", + Self::Error => "error", + Self::Import => "import", + Self::Ticket => "ticket", + Self::Lifecycle => "lifecycle", + Self::Network => "network", + Self::Download => "download", + Self::Export => "export", + Self::Access => "access", + Self::Handshake => "handshake", + Self::Approval => "approval", + Self::Transfer => "transfer", + } + } +} + +struct EventKind(String); + +impl EventKind { + fn parse(value: &str) -> Option { + let valid = !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'); + valid.then(|| Self(value.to_string())) + } +} + enum EventCommand { Persist(CoreEvent), Flush(oneshot::Sender<()>), @@ -20,19 +111,24 @@ enum EventCommand { pub(crate) struct EventHub { sink: Arc, - tx: mpsc::UnboundedSender, + tx: mpsc::Sender, join: TokioMutex>>, sequence: Mutex, } impl EventHub { - pub(crate) fn start(repository: Repository, sink: Arc) -> Self { - let (tx, mut rx) = mpsc::unbounded_channel(); + pub(crate) fn start( + repository: Repository, + sink: Arc, + queue_capacity: usize, + max_history: u64, + ) -> Self { + let (tx, mut rx) = mpsc::channel(queue_capacity); let join = tokio::spawn(async move { while let Some(command) = rx.recv().await { match command { EventCommand::Persist(event) => { - if let Err(error) = repository.insert_event(&event).await { + if let Err(error) = repository.insert_event(&event, max_history).await { tracing::warn!(%error, event_id = %event.id, "failed to persist core event"); } } @@ -56,7 +152,15 @@ impl EventHub { } pub(crate) fn emit_endpoint(&self, phase: &str, kind: &str, data: Value) { - self.emit("endpoint", None, None, phase, kind, data); + let Some(phase) = EventPhase::parse(phase) else { + tracing::warn!(phase, "dropped event with unknown phase"); + return; + }; + let Some(kind) = EventKind::parse(kind) else { + tracing::warn!(kind, "dropped event with invalid kind"); + return; + }; + self.emit(EventScope::Endpoint, None, None, phase, kind, data); } pub(crate) fn emit_transfer( @@ -67,10 +171,22 @@ impl EventHub { kind: &str, data: Value, ) { + let Ok(direction) = TransferDirection::try_from(direction) else { + tracing::warn!(direction, "dropped event with unknown direction"); + return; + }; + let Some(phase) = EventPhase::parse(phase) else { + tracing::warn!(phase, "dropped event with unknown phase"); + return; + }; + let Some(kind) = EventKind::parse(kind) else { + tracing::warn!(kind, "dropped event with invalid kind"); + return; + }; self.emit( - "transfer", + EventScope::Transfer, Some(transfer_id), - Some(direction.to_string()), + Some(direction), phase, kind, data, @@ -79,14 +195,14 @@ impl EventHub { pub(crate) async fn flush(&self) { let (tx, rx) = oneshot::channel(); - if self.tx.send(EventCommand::Flush(tx)).is_ok() { + if self.tx.send(EventCommand::Flush(tx)).await.is_ok() { let _ = rx.await; } } pub(crate) async fn shutdown(&self) { let (tx, rx) = oneshot::channel(); - if self.tx.send(EventCommand::Shutdown(tx)).is_ok() { + if self.tx.send(EventCommand::Shutdown(tx)).await.is_ok() { let _ = rx.await; } if let Some(join) = self.join.lock().await.take() { @@ -96,15 +212,20 @@ impl EventHub { fn emit( &self, - scope: &str, + scope: EventScope, transfer_id: Option, - direction: Option, - phase: &str, - kind: &str, + direction: Option, + phase: EventPhase, + kind: EventKind, data: Value, ) { let timestamp = now_ms(); - let mut sequence = self.sequence.lock().expect("event sequence lock poisoned"); + // A panic while formatting a previous event cannot invalidate an + // integer counter, so recover the guard instead of cascading a panic. + let mut sequence = self + .sequence + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let id = format!("{timestamp}-{}", *sequence); *sequence += 1; drop(sequence); @@ -115,15 +236,15 @@ impl EventHub { let event = CoreEvent { id, timestamp, - scope: scope.to_string(), + scope: scope.as_str().to_string(), transfer_id, - direction, - phase: phase.to_string(), - kind: kind.to_string(), + direction: direction.map(|direction| direction.as_str().to_string()), + phase: phase.as_str().to_string(), + kind: kind.0, data_json: data.to_string(), }; - if self.tx.send(EventCommand::Persist(event.clone())).is_err() { - tracing::warn!(event_id = %event.id, "event persistence queue is closed"); + if let Err(error) = self.tx.try_send(EventCommand::Persist(event.clone())) { + tracing::warn!(event_id = %event.id, %error, "event persistence queue dropped event"); } self.sink.on_event(event); } diff --git a/crates/vnidrop/src/filesystem.rs b/crates/vnidrop/src/filesystem.rs index 0180e14..44d0056 100644 --- a/crates/vnidrop/src/filesystem.rs +++ b/crates/vnidrop/src/filesystem.rs @@ -1,21 +1,27 @@ #[cfg(unix)] use std::os::fd::{FromRawFd, OwnedFd}; use std::{ - fs::File, + fs::{File, OpenOptions}, io::{self, Read, Write}, path::{Component, Path, PathBuf}, + time::{Duration, SystemTime}, }; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; + use anyhow::{Context, Result}; use bytes::Bytes; use iroh_blobs::{api::TempTag, Hash}; +use uuid::Uuid; use crate::{ - api::{ShareSource, SourceKind}, + api::{CoreLimits, ShareSource, SourceKind}, util::non_empty, }; const STREAM_BUFFER_LEN: usize = 1024 * 1024; +const STALE_PART_AGE: Duration = Duration::from_secs(24 * 60 * 60); #[derive(Debug)] pub(crate) struct TransferImport { @@ -39,6 +45,109 @@ pub(crate) enum ImportSource { FileDescriptor(OwnedFd), } +pub(crate) struct AtomicOutputFile { + target: PathBuf, + temporary: PathBuf, + committed: bool, +} + +impl AtomicOutputFile { + pub(crate) fn create(output_dir: &Path, relative_path: &str) -> Result<(Self, File)> { + let target = safe_output_path(output_dir, relative_path)?; + let parent = target + .parent() + .context("output file must have a parent directory")?; + std::fs::create_dir_all(parent)?; + + let canonical_root = std::fs::canonicalize(output_dir)?; + let canonical_parent = std::fs::canonicalize(parent)?; + if !canonical_parent.starts_with(&canonical_root) { + anyhow::bail!("output path escapes the selected directory"); + } + cleanup_stale_temporary_files(parent, STALE_PART_AGE)?; + if std::fs::symlink_metadata(&target).is_ok() { + anyhow::bail!("destination already exists: {}", target.display()); + } + + let final_name = target + .file_name() + .and_then(|name| name.to_str()) + .context("output filename is not valid UTF-8")?; + let temporary = parent.join(format!(".{final_name}.vnidrop-{}.part", Uuid::new_v4())); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.custom_flags(libc::O_NOFOLLOW); + let file = options + .open(&temporary) + .with_context(|| format!("failed to create {}", temporary.display()))?; + + // Recheck after opening the temporary file so a swapped ancestor is + // detected before bytes are published to the final destination. + let canonical_parent_after_open = std::fs::canonicalize(parent)?; + if canonical_parent_after_open != canonical_parent { + let _ = std::fs::remove_file(&temporary); + anyhow::bail!("output directory changed while opening destination"); + } + + Ok(( + Self { + target, + temporary, + committed: false, + }, + file, + )) + } + + pub(crate) fn commit(mut self) -> Result<()> { + // Creating a hard link is an atomic no-clobber publication on the same + // filesystem. It fails if another writer created the destination. + std::fs::hard_link(&self.temporary, &self.target) + .with_context(|| format!("failed to commit {}", self.target.display()))?; + self.committed = true; + if let Err(error) = std::fs::remove_file(&self.temporary) { + tracing::warn!(%error, path = %self.temporary.display(), "failed to remove committed temporary file"); + } + Ok(()) + } +} + +pub(crate) fn cleanup_stale_temporary_files( + directory: &Path, + minimum_age: Duration, +) -> Result { + let now = SystemTime::now(); + let mut removed = 0; + for entry in std::fs::read_dir(directory)? { + let entry = entry?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if !(name.starts_with('.') && name.contains(".vnidrop-") && name.ends_with(".part")) { + continue; + } + let metadata = entry.metadata()?; + let age = metadata + .modified() + .ok() + .and_then(|modified| now.duration_since(modified).ok()) + .unwrap_or_default(); + if age >= minimum_age && metadata.is_file() { + std::fs::remove_file(entry.path())?; + removed += 1; + } + } + Ok(removed) +} + +impl Drop for AtomicOutputFile { + fn drop(&mut self) { + if !self.committed { + let _ = std::fs::remove_file(&self.temporary); + } + } +} + impl ImportSource { pub(crate) fn open(self) -> Result { match self { @@ -51,7 +160,22 @@ impl ImportSource { } } +#[cfg(test)] pub(crate) fn collect_import_files(sources: Vec) -> Result> { + collect_import_files_with_limits(sources, &CoreLimits::default()) +} + +pub(crate) fn collect_import_files_with_limits( + sources: Vec, + limits: &CoreLimits, +) -> Result> { + if sources.len() as u64 > limits.max_sources { + anyhow::bail!( + "source count {} exceeds limit {}", + sources.len(), + limits.max_sources + ); + } let mut files = Vec::new(); for source in sources { match source.kind { @@ -118,6 +242,34 @@ pub(crate) fn collect_import_files(sources: Vec) -> Result limits.max_collection_files { + anyhow::bail!( + "collection file count {} exceeds limit {}", + files.len(), + limits.max_collection_files + ); + } + let mut known_total = 0u64; + for file in &files { + if file.collection_name.len() as u64 > limits.max_path_bytes { + anyhow::bail!( + "collection path exceeds {} bytes: {}", + limits.max_path_bytes, + file.collection_name + ); + } + if let ImportSource::Path(path) = &file.source { + known_total = known_total + .checked_add(std::fs::metadata(path)?.len()) + .context("collection size overflow")?; + if known_total > limits.max_total_bytes { + anyhow::bail!( + "collection size {known_total} exceeds limit {}", + limits.max_total_bytes + ); + } + } + } Ok(files) } @@ -212,7 +364,7 @@ where pub(crate) fn write_stream_to_blocking_writer( mut writer: W, rx: async_channel::Receiver>>, -) -> io::Result<()> +) -> io::Result where W: Write, { @@ -222,12 +374,13 @@ where None => break, } } - writer.flush() + writer.flush()?; + Ok(writer) } -pub(crate) async fn wait_for_writer( - task: std::thread::JoinHandle>, -) -> Result> { +pub(crate) async fn wait_for_writer( + task: std::thread::JoinHandle>, +) -> Result> { tokio::task::spawn_blocking(move || { task.join() .map_err(|_| anyhow::anyhow!("export writer thread panicked")) diff --git a/crates/vnidrop/src/handshake.rs b/crates/vnidrop/src/handshake.rs index 2a80d95..385a220 100644 --- a/crates/vnidrop/src/handshake.rs +++ b/crates/vnidrop/src/handshake.rs @@ -24,7 +24,7 @@ impl fmt::Debug for HandshakeService { } impl HandshakeService { - pub(crate) const ALPN: &'static [u8] = b"/vnidrop/handshake/1"; + pub(crate) const ALPN: &'static [u8] = b"/vnidrop/handshake/2"; pub(crate) fn new(approval: crate::approval::ApprovalService) -> Self { Self { approval } @@ -64,6 +64,14 @@ impl ProtocolHandler for HandshakeService { let response = self.handle_request(remote_endpoint_id.clone(), inner).await; let _ = tx.send(response).await; } + HandshakeMessage::ReportDelivery(message) => { + let WithChannels { inner, tx, .. } = message; + let response = self + .approval + .complete_delivery(remote_endpoint_id.clone(), inner) + .await; + let _ = tx.send(response).await; + } } } @@ -94,6 +102,13 @@ impl HandshakeClient { }) .await } + + pub(crate) async fn report_delivery( + &self, + receipt: DeliveryReceipt, + ) -> Result { + self.inner.rpc(receipt).await + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -108,8 +123,27 @@ pub(crate) struct RequestTransfer { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub(crate) enum HandshakeResponse { - Approved { token: String, expires_at: i64 }, - Denied { reason: String }, + Approved { + request_id: String, + token: String, + expires_at: i64, + }, + Denied { + reason: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct DeliveryReceipt { + pub(crate) request_id: String, + pub(crate) transfer_id: u64, + pub(crate) token: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) enum DeliveryReceiptResponse { + Recorded, + Rejected { reason: String }, } #[rpc_requests(message = HandshakeMessage)] @@ -117,4 +151,6 @@ pub(crate) enum HandshakeResponse { enum HandshakeProtocol { #[rpc(tx=oneshot::Sender)] RequestTransfer(RequestTransfer), + #[rpc(tx=oneshot::Sender)] + ReportDelivery(DeliveryReceipt), } diff --git a/crates/vnidrop/src/lib.rs b/crates/vnidrop/src/lib.rs index 1de9c15..d6bce50 100644 --- a/crates/vnidrop/src/lib.rs +++ b/crates/vnidrop/src/lib.rs @@ -10,12 +10,13 @@ mod repository; mod runtime; mod secret; mod ticket; +mod transfer_state; mod util; pub use api::{ - CoreEvent, CoreEventSink, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult, - ShareSource, SourceKind, StoredTransfer, TicketInspection, TransferAccessMode, - TransferMetadata, + default_core_limits, CoreEvent, CoreEventSink, CoreLimits, ReceiveOutputSink, ReceiverRequest, + RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, + TicketInspection, TransferAccessMode, TransferMetadata, }; pub use error::VnidropError; pub use runtime::VnidropCore; diff --git a/crates/vnidrop/src/repository.rs b/crates/vnidrop/src/repository.rs index 1528b99..fe347be 100644 --- a/crates/vnidrop/src/repository.rs +++ b/crates/vnidrop/src/repository.rs @@ -1,30 +1,59 @@ use std::{path::Path, str::FromStr}; -use anyhow::Result; +#[cfg(test)] +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; + +use anyhow::{Context, Result}; use sqlx::{ sqlite::{SqliteConnectOptions, SqlitePoolOptions}, Row, SqlitePool, }; +use uuid::Uuid; -use crate::api::{CoreEvent, ReceiverRequest, StoredTransfer}; -use crate::util::now_ms; +use crate::{ + access_policy::mode_from_storage, + api::{CoreEvent, ReceiverRequest, StoredTransfer}, + transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus}, + util::now_ms, +}; -const SCHEMA_VERSION: i64 = 1; +const SCHEMA_VERSION: i64 = 4; #[derive(Debug, Clone)] pub(crate) struct Repository { pool: SqlitePool, + #[cfg(test)] + fail_next_write: Arc, } pub(crate) struct TransferUpsert<'a> { pub(crate) transfer_id: u64, - pub(crate) direction: &'a str, - pub(crate) status: &'a str, + pub(crate) peer_id: Option<&'a str>, + pub(crate) direction: TransferDirection, + pub(crate) status: TransferStatus, pub(crate) transfer_name: Option<&'a str>, pub(crate) content_hash: Option<&'a str>, pub(crate) ticket: Option<&'a str>, pub(crate) file_count: u64, pub(crate) total_size: u64, + pub(crate) access_mode: &'a str, +} + +#[derive(Debug, Clone)] +pub(crate) struct PersistedShare { + pub(crate) transfer_id: u64, + pub(crate) content_hash: String, + pub(crate) access_mode: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RecoveredTransfer { + pub(crate) transfer_id: u64, + pub(crate) direction: TransferDirection, + pub(crate) previous_status: TransferStatus, } pub(crate) struct ReceiverRequestInsert<'a> { @@ -47,7 +76,11 @@ impl Repository { .max_connections(4) .connect_with(options) .await?; - let repository = Self { pool }; + let repository = Self { + pool, + #[cfg(test)] + fail_next_write: Arc::new(AtomicBool::new(false)), + }; repository.ensure_schema().await?; Ok(repository) } @@ -59,6 +92,9 @@ impl Repository { r#" CREATE TABLE IF NOT EXISTS transfers ( transfer_id INTEGER PRIMARY KEY, + local_id TEXT NOT NULL, + protocol_transfer_id INTEGER NOT NULL, + peer_id TEXT, direction TEXT NOT NULL, status TEXT NOT NULL, transfer_name TEXT, @@ -66,6 +102,7 @@ impl Repository { ticket TEXT, file_count INTEGER NOT NULL DEFAULT 0, total_size INTEGER NOT NULL DEFAULT 0, + access_mode TEXT NOT NULL DEFAULT 'approval_required', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); @@ -74,6 +111,60 @@ impl Repository { .execute(&self.pool) .await?; + let columns = sqlx::query("PRAGMA table_info(transfers)") + .fetch_all(&self.pool) + .await?; + let has_access_mode = columns + .iter() + .any(|row| row.get::(1) == "access_mode"); + if !has_access_mode { + sqlx::query( + "ALTER TABLE transfers ADD COLUMN access_mode TEXT NOT NULL DEFAULT 'approval_required'", + ) + .execute(&self.pool) + .await?; + } + + let has_local_id = columns + .iter() + .any(|row| row.get::(1) == "local_id"); + if !has_local_id { + sqlx::query("ALTER TABLE transfers ADD COLUMN local_id TEXT") + .execute(&self.pool) + .await?; + } + let has_protocol_transfer_id = columns + .iter() + .any(|row| row.get::(1) == "protocol_transfer_id"); + if !has_protocol_transfer_id { + sqlx::query("ALTER TABLE transfers ADD COLUMN protocol_transfer_id INTEGER") + .execute(&self.pool) + .await?; + } + let has_peer_id = columns + .iter() + .any(|row| row.get::(1) == "peer_id"); + if !has_peer_id { + sqlx::query("ALTER TABLE transfers ADD COLUMN peer_id TEXT") + .execute(&self.pool) + .await?; + } + sqlx::query( + r#" + UPDATE transfers + SET local_id = COALESCE(local_id, 'legacy-' || transfer_id || '-' || direction), + protocol_transfer_id = COALESCE(protocol_transfer_id, transfer_id) + WHERE local_id IS NULL OR protocol_transfer_id IS NULL + "#, + ) + .execute(&self.pool) + .await?; + sqlx::query( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_transfers_local_id ON transfers(local_id)", + ) + .execute(&self.pool) + .await?; + sqlx::query( r#" CREATE TABLE IF NOT EXISTS transfer_events ( @@ -111,12 +202,34 @@ impl Repository { reason TEXT, requested_at INTEGER NOT NULL, responded_at INTEGER + ,receipt_token_hash TEXT + ,completed_at INTEGER ); "#, ) .execute(&self.pool) .await?; + let receiver_columns = sqlx::query("PRAGMA table_info(receiver_requests)") + .fetch_all(&self.pool) + .await?; + if !receiver_columns + .iter() + .any(|row| row.get::(1) == "receipt_token_hash") + { + sqlx::query("ALTER TABLE receiver_requests ADD COLUMN receipt_token_hash TEXT") + .execute(&self.pool) + .await?; + } + if !receiver_columns + .iter() + .any(|row| row.get::(1) == "completed_at") + { + sqlx::query("ALTER TABLE receiver_requests ADD COLUMN completed_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);", ) @@ -137,55 +250,275 @@ impl Repository { Ok(row.get(0)) } - pub(crate) async fn upsert_transfer(&self, transfer: TransferUpsert<'_>) -> Result<()> { + pub(crate) async fn insert_transfer(&self, transfer: TransferUpsert<'_>) -> Result<()> { + self.maybe_fail_write()?; let now = now_ms(); sqlx::query( r#" INSERT INTO transfers ( - transfer_id, direction, status, transfer_name, content_hash, ticket, - file_count, total_size, created_at, updated_at + transfer_id, local_id, protocol_transfer_id, peer_id, direction, status, + transfer_name, content_hash, ticket, file_count, total_size, access_mode, + created_at, updated_at ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9) - ON CONFLICT(transfer_id) DO UPDATE SET - direction = excluded.direction, - status = excluded.status, - transfer_name = excluded.transfer_name, - content_hash = excluded.content_hash, - ticket = excluded.ticket, - file_count = excluded.file_count, - total_size = excluded.total_size, - updated_at = excluded.updated_at; + VALUES (?1, ?2, ?1, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?12); "#, ) - .bind(transfer.transfer_id as i64) - .bind(transfer.direction) - .bind(transfer.status) + .bind(to_db_id(transfer.transfer_id)?) + .bind(Uuid::new_v4().to_string()) + .bind(transfer.peer_id) + .bind(transfer.direction.as_str()) + .bind(transfer.status.as_str()) .bind(transfer.transfer_name) .bind(transfer.content_hash) .bind(transfer.ticket) - .bind(transfer.file_count as i64) - .bind(transfer.total_size as i64) + .bind(to_db_id(transfer.file_count)?) + .bind(to_db_id(transfer.total_size)?) + .bind(transfer.access_mode) .bind(now) .execute(&self.pool) .await?; Ok(()) } - pub(crate) async fn update_transfer_status( - &self, - transfer_id: u64, - status: &str, - ) -> Result<()> { - sqlx::query("UPDATE transfers SET status = ?1, updated_at = ?2 WHERE transfer_id = ?3") - .bind(status) - .bind(now_ms()) - .bind(transfer_id as i64) - .execute(&self.pool) - .await?; + pub(crate) async fn start_receive(&self, transfer: TransferUpsert<'_>) -> Result<()> { + self.maybe_fail_write()?; + if transfer.direction != TransferDirection::Receive + || transfer.status != TransferStatus::Receiving + { + anyhow::bail!("receive must start in the receiving state"); + } + let now = now_ms(); + let result = sqlx::query( + r#" + INSERT INTO transfers ( + transfer_id, local_id, protocol_transfer_id, peer_id, direction, status, + transfer_name, content_hash, ticket, file_count, total_size, access_mode, + created_at, updated_at + ) + VALUES (?1, ?2, ?1, ?3, 'receive', 'receiving', ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?10) + ON CONFLICT(transfer_id) DO UPDATE SET + status = 'receiving', + transfer_name = excluded.transfer_name, + content_hash = excluded.content_hash, + ticket = excluded.ticket, + file_count = excluded.file_count, + total_size = excluded.total_size, + access_mode = excluded.access_mode, + peer_id = excluded.peer_id, + updated_at = excluded.updated_at + WHERE transfers.direction = 'receive' + AND transfers.status IN ('done', 'failed', 'cancelled') + "#, + ) + .bind(to_db_id(transfer.transfer_id)?) + .bind(Uuid::new_v4().to_string()) + .bind(transfer.peer_id) + .bind(transfer.transfer_name) + .bind(transfer.content_hash) + .bind(transfer.ticket) + .bind(to_db_id(transfer.file_count)?) + .bind(to_db_id(transfer.total_size)?) + .bind(transfer.access_mode) + .bind(now) + .execute(&self.pool) + .await?; + require_one_changed(result.rows_affected(), "start receive")?; Ok(()) } - pub(crate) async fn insert_event(&self, event: &CoreEvent) -> Result<()> { + pub(crate) async fn complete_share_import(&self, transfer: TransferUpsert<'_>) -> Result<()> { + self.maybe_fail_write()?; + if transfer.direction != TransferDirection::Send + || transfer.status != TransferStatus::Sharing + { + anyhow::bail!("share import must complete in the sharing state"); + } + let result = sqlx::query( + r#" + UPDATE transfers + SET status = ?1, + transfer_name = ?2, + content_hash = ?3, + ticket = ?4, + file_count = ?5, + total_size = ?6, + access_mode = ?7, + updated_at = ?8 + WHERE transfer_id = ?9 + AND direction = 'send' + AND status = 'importing' + "#, + ) + .bind(transfer.status.as_str()) + .bind(transfer.transfer_name) + .bind(transfer.content_hash) + .bind(transfer.ticket) + .bind(to_db_id(transfer.file_count)?) + .bind(to_db_id(transfer.total_size)?) + .bind(transfer.access_mode) + .bind(now_ms()) + .bind(to_db_id(transfer.transfer_id)?) + .execute(&self.pool) + .await?; + require_one_changed(result.rows_affected(), "complete share import")?; + Ok(()) + } + + pub(crate) async fn transition_transfer_status( + &self, + transfer_id: u64, + expected: TransferStatus, + next: TransferStatus, + ) -> Result<()> { + self.maybe_fail_write()?; + if !expected.can_transition_to(next) { + anyhow::bail!( + "illegal transfer status transition: {} -> {}", + expected.as_str(), + next.as_str() + ); + } + let result = sqlx::query( + r#" + UPDATE transfers + SET status = ?1, updated_at = ?2 + WHERE transfer_id = ?3 AND status = ?4 + "#, + ) + .bind(next.as_str()) + .bind(now_ms()) + .bind(to_db_id(transfer_id)?) + .bind(expected.as_str()) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + let current = sqlx::query("SELECT status FROM transfers WHERE transfer_id = ?1") + .bind(to_db_id(transfer_id)?) + .fetch_optional(&self.pool) + .await?; + if current + .as_ref() + .map(|row| row.get::(0)) + .as_deref() + == Some(next.as_str()) + { + return Ok(()); + } + } + require_one_changed(result.rows_affected(), "transition transfer status")?; + Ok(()) + } + + pub(crate) async fn update_active_share_access_mode( + &self, + transfer_id: u64, + access_mode: &str, + ) -> Result<()> { + self.maybe_fail_write()?; + let result = sqlx::query( + r#" + UPDATE transfers + SET access_mode = ?1, updated_at = ?2 + WHERE transfer_id = ?3 + AND direction = 'send' + AND status = 'sharing' + "#, + ) + .bind(access_mode) + .bind(now_ms()) + .bind(to_db_id(transfer_id)?) + .execute(&self.pool) + .await?; + require_one_changed(result.rows_affected(), "update active share access mode")?; + Ok(()) + } + + pub(crate) async fn recover_interrupted_transfers(&self) -> Result> { + self.maybe_fail_write()?; + let mut transaction = self.pool.begin().await?; + let rows = sqlx::query( + r#" + SELECT transfer_id, direction, status + FROM transfers + WHERE status IN ('importing', 'receiving') + ORDER BY created_at ASC + "#, + ) + .fetch_all(&mut *transaction) + .await?; + let recovered = rows + .into_iter() + .map(|row| { + Ok(RecoveredTransfer { + transfer_id: row.get::("transfer_id") as u64, + direction: TransferDirection::try_from( + row.get::("direction").as_str(), + )?, + previous_status: TransferStatus::try_from( + row.get::("status").as_str(), + )?, + }) + }) + .collect::>>()?; + + if !recovered.is_empty() { + sqlx::query( + r#" + UPDATE transfers + SET status = 'failed', updated_at = ?1 + WHERE status IN ('importing', 'receiving') + "#, + ) + .bind(now_ms()) + .execute(&mut *transaction) + .await?; + } + transaction.commit().await?; + Ok(recovered) + } + + #[cfg(test)] + pub(crate) fn fail_next_write(&self) { + self.fail_next_write.store(true, Ordering::SeqCst); + } + + #[cfg(test)] + fn maybe_fail_write(&self) -> Result<()> { + if self.fail_next_write.swap(false, Ordering::SeqCst) { + anyhow::bail!("injected repository write failure"); + } + Ok(()) + } + + #[cfg(not(test))] + fn maybe_fail_write(&self) -> Result<()> { + Ok(()) + } + + pub(crate) async fn list_active_shares(&self) -> Result> { + let rows = sqlx::query( + r#" + SELECT transfer_id, content_hash, access_mode + FROM transfers + WHERE direction = 'send' + AND status = 'sharing' + AND content_hash IS NOT NULL + "#, + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .into_iter() + .map(|row| PersistedShare { + transfer_id: row.get::(0) as u64, + content_hash: row.get::(1), + access_mode: row.get::(2), + }) + .collect()) + } + + pub(crate) async fn insert_event(&self, event: &CoreEvent, max_history: u64) -> Result<()> { + let mut transaction = self.pool.begin().await?; sqlx::query( r#" INSERT OR REPLACE INTO transfer_events ( @@ -197,13 +530,25 @@ impl Repository { .bind(&event.id) .bind(event.timestamp) .bind(&event.scope) - .bind(event.transfer_id.map(|value| value as i64)) + .bind(event.transfer_id.map(to_db_id).transpose()?) .bind(&event.direction) .bind(&event.phase) .bind(&event.kind) .bind(&event.data_json) - .execute(&self.pool) + .execute(&mut *transaction) .await?; + sqlx::query( + r#" + DELETE FROM transfer_events + WHERE id NOT IN ( + SELECT id FROM transfer_events ORDER BY timestamp DESC, id DESC LIMIT ?1 + ) + "#, + ) + .bind(to_db_id(max_history)?) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; Ok(()) } @@ -222,7 +567,7 @@ impl Repository { "#, ) .bind(request.id) - .bind(request.transfer_id as i64) + .bind(to_db_id(request.transfer_id)?) .bind(request.remote_endpoint_id) .bind(request.transfer_name) .bind(request.receiver_name) @@ -237,7 +582,7 @@ impl Repository { pub(crate) async fn update_receiver_request_status( &self, id: &str, - status: &str, + status: ReceiverRequestStatus, reason: Option<&str>, ) -> Result<()> { let result = sqlx::query( @@ -248,7 +593,7 @@ impl Repository { AND status = 'requested' "#, ) - .bind(status) + .bind(status.as_str()) .bind(reason) .bind(now_ms()) .bind(id) @@ -260,6 +605,85 @@ impl Repository { Ok(()) } + pub(crate) async fn set_receiver_receipt_token( + &self, + id: &str, + token_hash: &str, + ) -> Result<()> { + let result = sqlx::query( + "UPDATE receiver_requests SET receipt_token_hash = ?1 WHERE id = ?2 AND status = 'accepted'", + ) + .bind(token_hash) + .bind(id) + .execute(&self.pool) + .await?; + require_one_changed(result.rows_affected(), "attach receiver receipt token") + } + + pub(crate) async fn complete_receiver_delivery( + &self, + id: &str, + transfer_id: u64, + remote_endpoint_id: &str, + token_hash: &str, + ) -> Result<()> { + let result = sqlx::query( + r#" + UPDATE receiver_requests + SET status = 'completed', completed_at = ?1 + WHERE id = ?2 AND transfer_id = ?3 AND remote_endpoint_id = ?4 AND receipt_token_hash = ?5 + AND status = 'accepted' + "#, + ) + .bind(now_ms()) + .bind(id) + .bind(to_db_id(transfer_id)?) + .bind(remote_endpoint_id) + .bind(token_hash) + .execute(&self.pool) + .await?; + if result.rows_affected() == 1 { + return Ok(()); + } + let already_recorded = sqlx::query( + r#" + SELECT EXISTS( + SELECT 1 FROM receiver_requests + WHERE id = ?1 AND transfer_id = ?2 AND remote_endpoint_id = ?3 + AND receipt_token_hash = ?4 AND status = 'completed' + ) + "#, + ) + .bind(id) + .bind(to_db_id(transfer_id)?) + .bind(remote_endpoint_id) + .bind(token_hash) + .fetch_one(&self.pool) + .await? + .get::(0) + != 0; + if already_recorded { + Ok(()) + } else { + anyhow::bail!("delivery receipt did not match an accepted receiver request") + } + } + + pub(crate) async fn expire_pending_receiver_requests(&self, reason: &str) -> Result { + let result = sqlx::query( + r#" + UPDATE receiver_requests + SET status = 'expired', reason = ?1, responded_at = ?2 + WHERE status = 'requested' + "#, + ) + .bind(reason) + .bind(now_ms()) + .execute(&self.pool) + .await?; + Ok(result.rows_affected()) + } + pub(crate) async fn list_receiver_requests( &self, transfer_id: u64, @@ -268,13 +692,13 @@ impl Repository { r#" SELECT id, transfer_id, remote_endpoint_id, transfer_name, receiver_name, receiver_device_name, app_version, status, - reason, requested_at, responded_at + reason, requested_at, responded_at, completed_at FROM receiver_requests WHERE transfer_id = ?1 ORDER BY requested_at DESC "#, ) - .bind(transfer_id as i64) + .bind(to_db_id(transfer_id)?) .fetch_all(&self.pool) .await?; Ok(rows.into_iter().map(row_to_receiver_request).collect()) @@ -292,7 +716,7 @@ impl Repository { ) "#, ) - .bind(transfer_id as i64) + .bind(to_db_id(transfer_id)?) .bind(content_hash) .fetch_one(&self.pool) .await?; @@ -303,17 +727,43 @@ impl Repository { let rows = sqlx::query( r#" SELECT transfer_id, direction, status, transfer_name, content_hash, ticket, - file_count, total_size, created_at, updated_at + local_id, protocol_transfer_id, peer_id, + file_count, total_size, access_mode, created_at, updated_at FROM transfers ORDER BY updated_at DESC "#, ) .fetch_all(&self.pool) .await?; - Ok(rows.into_iter().map(row_to_transfer).collect()) + rows.into_iter().map(row_to_transfer).collect() } - pub(crate) async fn list_events(&self, transfer_id: Option) -> Result> { + pub(crate) async fn delete_transfer(&self, transfer_id: u64) -> Result<()> { + self.maybe_fail_write()?; + let transfer_id = to_db_id(transfer_id)?; + let mut transaction = self.pool.begin().await?; + sqlx::query("DELETE FROM receiver_requests WHERE transfer_id = ?1") + .bind(transfer_id) + .execute(&mut *transaction) + .await?; + sqlx::query("DELETE FROM transfer_events WHERE transfer_id = ?1") + .bind(transfer_id) + .execute(&mut *transaction) + .await?; + let deleted = sqlx::query("DELETE FROM transfers WHERE transfer_id = ?1") + .bind(transfer_id) + .execute(&mut *transaction) + .await?; + require_one_changed(deleted.rows_affected(), "delete transfer")?; + transaction.commit().await?; + Ok(()) + } + + pub(crate) async fn list_events( + &self, + transfer_id: Option, + limit: u64, + ) -> Result> { let rows = if let Some(transfer_id) = transfer_id { sqlx::query( r#" @@ -321,9 +771,11 @@ impl Repository { FROM transfer_events WHERE transfer_id = ?1 ORDER BY timestamp ASC + LIMIT ?2 "#, ) - .bind(transfer_id as i64) + .bind(to_db_id(transfer_id)?) + .bind(to_db_id(limit)?) .fetch_all(&self.pool) .await? } else { @@ -332,9 +784,10 @@ impl Repository { SELECT id, timestamp, scope, transfer_id, direction, phase, kind, data_json FROM transfer_events ORDER BY timestamp DESC - LIMIT 500 + LIMIT ?1 "#, ) + .bind(to_db_id(limit)?) .fetch_all(&self.pool) .await? }; @@ -342,19 +795,39 @@ impl Repository { } } -fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> StoredTransfer { - StoredTransfer { +fn require_one_changed(rows_affected: u64, operation: &str) -> Result<()> { + if rows_affected != 1 { + anyhow::bail!("{operation} expected one matching transfer, changed {rows_affected}"); + } + Ok(()) +} + +fn to_db_id(value: u64) -> Result { + i64::try_from(value).context("transfer id exceeds SQLite signed integer range") +} + +fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> Result { + let direction = row.get::("direction"); + let status = row.get::("status"); + Ok(StoredTransfer { + local_id: row.get("local_id"), transfer_id: row.get::("transfer_id") as u64, - direction: row.get("direction"), - status: row.get("status"), + peer_id: row.get("peer_id"), + direction: TransferDirection::try_from(direction.as_str())? + .as_str() + .to_string(), + status: TransferStatus::try_from(status.as_str())? + .as_str() + .to_string(), transfer_name: row.get("transfer_name"), content_hash: row.get("content_hash"), ticket: row.get("ticket"), file_count: row.get::("file_count") as u64, total_size: row.get::("total_size") as u64, + access_mode: mode_from_storage(&row.get::("access_mode")), created_at: row.get("created_at"), updated_at: row.get("updated_at"), - } + }) } fn row_to_event(row: sqlx::sqlite::SqliteRow) -> CoreEvent { @@ -373,6 +846,7 @@ fn row_to_event(row: sqlx::sqlite::SqliteRow) -> CoreEvent { } fn row_to_receiver_request(row: sqlx::sqlite::SqliteRow) -> ReceiverRequest { + let status = row.get::("status"); ReceiverRequest { id: row.get("id"), transfer_id: row.get::("transfer_id") as u64, @@ -381,9 +855,12 @@ fn row_to_receiver_request(row: sqlx::sqlite::SqliteRow) -> ReceiverRequest { receiver_name: row.get("receiver_name"), receiver_device_name: row.get("receiver_device_name"), app_version: row.get("app_version"), - status: row.get("status"), + status: ReceiverRequestStatus::try_from(status.as_str()) + .map(|status| status.as_str().to_string()) + .unwrap_or_else(|_| "unknown".to_string()), reason: row.get("reason"), requested_at: row.get("requested_at"), responded_at: row.get("responded_at"), + completed_at: row.get("completed_at"), } } diff --git a/crates/vnidrop/src/runtime.rs b/crates/vnidrop/src/runtime.rs index 8d7c5a4..311126f 100644 --- a/crates/vnidrop/src/runtime.rs +++ b/crates/vnidrop/src/runtime.rs @@ -1,8 +1,8 @@ use std::{ collections::HashMap, - fs::File, io, path::{Path, PathBuf}, + str::FromStr, sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -25,39 +25,34 @@ use iroh_blobs::{ use n0_future::BufferedStreamExt; use serde_json::json; use tokio::{ - sync::{mpsc, oneshot, Mutex as TokioMutex}, + sync::{mpsc, oneshot, Mutex as TokioMutex, Semaphore}, task::JoinHandle, }; use crate::{ - access_policy::{AccessDecision, AccessPolicy}, + access_policy::{mode_from_storage, mode_to_storage, AccessDecision, AccessPolicy}, api::{ - CoreEvent, CoreEventSink, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult, - ShareSource, StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata, + CoreEvent, CoreEventSink, CoreLimits, ReceiveOutputSink, ReceiverRequest, RuntimeStatus, + ShareMetadataInput, ShareResult, ShareSource, StoredTransfer, TicketInspection, + TransferAccessMode, TransferMetadata, }, approval::ApprovalService, error::VnidropError, event_hub::EventHub, filesystem::{ - collect_import_files, default_collection_name, platform_path, - read_stream_from_blocking_reader, safe_output_path, wait_for_writer, - write_stream_to_blocking_writer, TransferImport, + collect_import_files_with_limits, default_collection_name, platform_path, + read_stream_from_blocking_reader, validated_relative_string, wait_for_writer, + write_stream_to_blocking_writer, AtomicOutputFile, TransferImport, }, - handshake::{HandshakeResponse, HandshakeService}, + handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, HandshakeService}, logging::init_logging, repository::{Repository, TransferUpsert}, secret::load_or_create_secret, - ticket::{parse_transfer_ticket, ParsedTransferTicket, VnidropTicket}, + ticket::{parse_transfer_ticket_with_limits, ParsedTransferTicket, VnidropTicket}, + transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus}, util::{non_empty, unique_transfer_id}, }; -const STATUS_SHARING: &str = "sharing"; -const STATUS_RECEIVING: &str = "receiving"; -const STATUS_DONE: &str = "done"; -const STATUS_CANCELLED: &str = "cancelled"; -const STATUS_STOPPED: &str = "stopped"; -const STATUS_FAILED: &str = "failed"; - #[derive(uniffi::Object)] pub struct VnidropCore { runtime: tokio::runtime::Runtime, @@ -73,15 +68,82 @@ struct CoreInner { repository: Repository, event_hub: Arc, approval: ApprovalService, + limits: CoreLimits, + transfer_slots: Semaphore, access_policy: Arc, - active_transfers: TokioMutex>>, - active_shares: TokioMutex>, + active_transfers: TokioMutex>, + // Newly imported shares retain a TempTag for the lifetime of this process. + // Restored shares have no in-memory tag, but remain tracked so they can be + // counted and explicitly revoked after a restart. + active_shares: TokioMutex>>, hash_to_transfer: TokioMutex>, connection_endpoints: TokioMutex>, provider_task: TokioMutex>>, shutdown_started: AtomicBool, } +struct ActiveTransfer { + direction: TransferDirection, + cancel: oneshot::Sender<()>, +} + +enum ReceiveTarget { + Directory(PathBuf), + OutputSink(Arc), +} + +struct OutputSinkFile<'a> { + sink: &'a dyn ReceiveOutputSink, + relative_path: String, + terminal: bool, +} + +impl<'a> OutputSinkFile<'a> { + fn start(sink: &'a dyn ReceiveOutputSink, relative_path: String) -> Result { + sink.start_file(relative_path.clone()) + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + Ok(Self { + sink, + relative_path, + terminal: false, + }) + } + + fn write(&self, bytes: Vec) -> Result<()> { + self.sink + .write_chunk(self.relative_path.clone(), bytes) + .map_err(|error| anyhow::anyhow!(error.to_string())) + } + + fn finish(mut self) -> Result<()> { + // finish_file is terminal even when the foreign implementation reports + // an error; implementations must release their open resource before + // returning so Rust never invokes two terminal callbacks. + self.terminal = true; + self.sink + .finish_file(self.relative_path.clone()) + .map_err(|error| anyhow::anyhow!(error.to_string())) + } +} + +impl Drop for OutputSinkFile<'_> { + fn drop(&mut self) { + if !self.terminal { + self.terminal = true; + if let Err(error) = self.sink.abort_file( + self.relative_path.clone(), + "transfer interrupted before file completion".to_string(), + ) { + tracing::warn!( + %error, + relative_path = %self.relative_path, + "failed to abort receive output sink file" + ); + } + } + } +} + #[uniffi::export] impl VnidropCore { #[uniffi::constructor] @@ -89,13 +151,23 @@ impl VnidropCore { app_data_dir: String, event_sink: Arc, ) -> Result, VnidropError> { + Self::initialize_with_limits(app_data_dir, event_sink, CoreLimits::default()) + } + + #[uniffi::constructor] + pub fn initialize_with_limits( + app_data_dir: String, + event_sink: Arc, + limits: CoreLimits, + ) -> Result, VnidropError> { + limits.validate().map_err(VnidropError::initialization)?; let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_name("vnidrop") .build()?; let app_data_dir = PathBuf::from(app_data_dir); let inner = runtime - .block_on(CoreInner::start(app_data_dir, event_sink)) + .block_on(CoreInner::start(app_data_dir, event_sink, limits)) .map_err(VnidropError::initialization)?; Ok(Arc::new(Self { runtime, inner })) } @@ -120,8 +192,8 @@ impl VnidropCore { output_dir: String, receiver_name: Option, ) -> Result<(), VnidropError> { - if let Err(error) = - parse_transfer_ticket(&ticket).context("failed to parse transfer ticket") + if let Err(error) = parse_transfer_ticket_with_limits(&ticket, &self.inner.limits) + .context("failed to parse transfer ticket") { self.runtime.block_on(async { self.inner.emit_endpoint( @@ -139,12 +211,45 @@ impl VnidropCore { .map_err(VnidropError::transfer) } + pub fn receive_with_output_sink( + &self, + ticket: String, + output_sink: Arc, + receiver_name: Option, + ) -> Result<(), VnidropError> { + if let Err(error) = parse_transfer_ticket_with_limits(&ticket, &self.inner.limits) + .context("failed to parse transfer ticket") + { + self.runtime.block_on(async { + self.inner.emit_endpoint( + "error", + "invalid-ticket", + json!({ "reason": error.to_string() }), + ); + self.inner.event_hub.flush().await; + }); + return Err(VnidropError::ticket(error)); + } + self.runtime + .block_on( + self.inner + .receive_with_output_sink(ticket, output_sink, receiver_name), + ) + .map_err(VnidropError::transfer) + } + pub fn cancel_transfer(&self, transfer_id: u64) -> Result<(), VnidropError> { self.runtime .block_on(self.inner.cancel_transfer(transfer_id)) .map_err(VnidropError::transfer) } + pub fn delete_transfer(&self, transfer_id: u64) -> Result<(), VnidropError> { + self.runtime + .block_on(self.inner.delete_transfer(transfer_id)) + .map_err(VnidropError::transfer) + } + pub fn set_transfer_access_mode( &self, transfer_id: u64, @@ -201,7 +306,7 @@ impl VnidropCore { } pub fn inspect_ticket(&self, ticket: String) -> Result { - let parsed = parse_transfer_ticket(&ticket) + let parsed = parse_transfer_ticket_with_limits(&ticket, &self.inner.limits) .context("failed to parse transfer ticket") .map_err(VnidropError::ticket)?; Ok(TicketInspection { @@ -221,7 +326,11 @@ impl VnidropCore { } impl CoreInner { - async fn start(app_data_dir: PathBuf, event_sink: Arc) -> Result> { + async fn start( + app_data_dir: PathBuf, + event_sink: Arc, + limits: CoreLimits, + ) -> Result> { tokio::fs::create_dir_all(&app_data_dir).await?; init_logging(&app_data_dir)?; let secret_key = load_or_create_secret(&app_data_dir).await?; @@ -238,10 +347,76 @@ 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 recovered_transfers = repository.recover_interrupted_transfers().await?; + let event_hub = Arc::new(EventHub::start( + repository.clone(), + event_sink, + limits.event_queue_capacity as usize, + limits.max_events, + )); + for recovered in recovered_transfers { + event_hub.emit_transfer( + recovered.transfer_id, + recovered.direction.as_str(), + "recovery", + "interrupted-transfer-failed", + json!({ "previous_status": recovered.previous_status.as_str() }), + ); + } + let expired_requests = repository + .expire_pending_receiver_requests("application restarted before approval") + .await?; + if expired_requests > 0 { + event_hub.emit_endpoint( + "recovery", + "pending-approvals-expired", + json!({ "count": expired_requests }), + ); + } let access_policy = AccessPolicy::new(); - let approval = - ApprovalService::new(repository.clone(), event_hub.clone(), access_policy.clone()); + // Restore share ownership and access mode before the router can serve + // any request. Unknown persisted modes fail closed in mode_from_storage. + let mut restored_hashes = HashMap::new(); + let mut restored_active_shares = HashMap::new(); + for share in repository.list_active_shares().await? { + let transfer_id = share.transfer_id; + let valid_root = match Hash::from_str(&share.content_hash) { + Ok(hash) => { + store.blobs().has(hash).await.unwrap_or(false) + && Collection::load(hash, store.as_ref()).await.is_ok() + } + Err(_) => false, + }; + if !valid_root { + repository + .transition_transfer_status( + transfer_id, + TransferStatus::Sharing, + TransferStatus::Failed, + ) + .await?; + event_hub.emit_transfer( + transfer_id, + TransferDirection::Send.as_str(), + "recovery", + "share-root-missing-or-corrupt", + json!({ "content_hash": share.content_hash }), + ); + continue; + } + restored_hashes.insert(share.content_hash, transfer_id); + restored_active_shares.insert(transfer_id, None); + access_policy + .set_mode(transfer_id, mode_from_storage(&share.access_mode)) + .await; + } + let approval = ApprovalService::new( + repository.clone(), + event_hub.clone(), + access_policy.clone(), + limits.max_pending_approvals as usize, + limits.max_metadata_bytes, + ); let handshake = HandshakeService::new(approval.clone()); let router = Router::builder(endpoint.clone()) .accept(iroh_blobs::ALPN, blobs) @@ -255,10 +430,12 @@ impl CoreInner { repository, event_hub, approval, + transfer_slots: Semaphore::new(limits.max_concurrent_transfers as usize), + limits, access_policy, active_transfers: TokioMutex::new(HashMap::new()), - active_shares: TokioMutex::new(HashMap::new()), - hash_to_transfer: TokioMutex::new(HashMap::new()), + active_shares: TokioMutex::new(restored_active_shares), + hash_to_transfer: TokioMutex::new(restored_hashes), connection_endpoints: TokioMutex::new(HashMap::new()), provider_task: TokioMutex::new(None), shutdown_started: AtomicBool::new(false), @@ -291,20 +468,73 @@ impl CoreInner { sources: Vec, metadata: ShareMetadataInput, ) -> Result { + let _permit = self + .transfer_slots + .acquire() + .await + .context("transfer limiter is closed")?; let transfer_id = metadata.transfer_id; - let result = self.share_files_inner(sources, metadata).await; - if let Err(error) = &result { - self.emit_transfer( - transfer_id, - "send", - "error", - "failed", - json!({ "reason": error.to_string() }), + if sources.is_empty() { + anyhow::bail!("at least one source is required"); + } + if sources.len() as u64 > self.limits.max_sources { + anyhow::bail!( + "source count {} exceeds limit {}", + sources.len(), + self.limits.max_sources ); - let _ = self - .repository - .update_transfer_status(transfer_id, STATUS_FAILED) - .await; + } + self.limits + .validate_metadata_text("transfer name", metadata.transfer_name.as_deref())?; + self.limits + .validate_metadata_text("sender name", metadata.sender_name.as_deref())?; + self.repository + .insert_transfer(TransferUpsert { + transfer_id, + peer_id: None, + direction: TransferDirection::Send, + status: TransferStatus::Importing, + transfer_name: metadata.transfer_name.as_deref(), + content_hash: None, + ticket: None, + file_count: 0, + total_size: 0, + access_mode: mode_to_storage(&metadata.access_mode), + }) + .await?; + let (cancel, mut cancelled) = oneshot::channel(); + self.active_transfers.lock().await.insert( + transfer_id, + ActiveTransfer { + direction: TransferDirection::Send, + cancel, + }, + ); + let (result, was_cancelled) = tokio::select! { + result = self.share_files_inner(sources, metadata) => (result, false), + _ = &mut cancelled => (Err(anyhow::anyhow!("transfer cancelled")), true), + }; + self.active_transfers.lock().await.remove(&transfer_id); + if let Err(error) = &result { + if was_cancelled { + self.emit_transfer(transfer_id, "send", "lifecycle", "cancelled", json!({})); + } else { + self.emit_transfer( + transfer_id, + "send", + "error", + "failed", + json!({ "reason": error.to_string() }), + ); + let _ = self + .repository + .transition_transfer_status( + transfer_id, + TransferStatus::Importing, + TransferStatus::Failed, + ) + .await; + } } result } @@ -314,10 +544,7 @@ impl CoreInner { sources: Vec, metadata: ShareMetadataInput, ) -> Result { - if sources.is_empty() { - anyhow::bail!("at least one source is required"); - } - + let access_mode = metadata.access_mode.clone(); self.emit_transfer( metadata.transfer_id, "send", @@ -343,30 +570,36 @@ impl CoreInner { let ticket = VnidropTicket::new(blob_ticket.clone(), ticket_metadata) .encode() .context("failed to encode VniDrop transfer ticket")?; + let content_hash = import.root_hash.to_string(); + // Persist the completed share before exposing it through the provider. + // The remaining in-memory registrations are infallible and can be + // reconstructed from SQLite if the process exits immediately after. + self.repository + .complete_share_import(TransferUpsert { + transfer_id: metadata.transfer_id, + peer_id: None, + direction: TransferDirection::Send, + status: TransferStatus::Sharing, + transfer_name: Some(&transfer_name), + content_hash: Some(&content_hash), + ticket: Some(&ticket), + file_count: import.file_count, + total_size: import.total_size, + access_mode: mode_to_storage(&access_mode), + }) + .await?; self.hash_to_transfer .lock() .await - .insert(import.root_hash.to_string(), metadata.transfer_id); + .insert(content_hash, metadata.transfer_id); self.access_policy - .set_mode(metadata.transfer_id, TransferAccessMode::ApprovalRequired) + .set_mode(metadata.transfer_id, access_mode) .await; self.active_shares .lock() .await - .insert(metadata.transfer_id, import.tag); - self.repository - .upsert_transfer(TransferUpsert { - transfer_id: metadata.transfer_id, - direction: "send", - status: STATUS_SHARING, - transfer_name: Some(&transfer_name), - content_hash: Some(&import.root_hash.to_string()), - ticket: Some(&ticket), - file_count: import.file_count, - total_size: import.total_size, - }) - .await?; + .insert(metadata.transfer_id, Some(import.tag)); self.emit_transfer( metadata.transfer_id, @@ -398,7 +631,37 @@ impl CoreInner { output_dir: PathBuf, receiver_name: Option, ) -> Result<()> { - let parsed = match parse_transfer_ticket(&ticket).context("failed to parse transfer ticket") + self.receive_to_target(ticket, ReceiveTarget::Directory(output_dir), receiver_name) + .await + } + + async fn receive_with_output_sink( + self: &Arc, + ticket: String, + output_sink: Arc, + receiver_name: Option, + ) -> Result<()> { + self.receive_to_target( + ticket, + ReceiveTarget::OutputSink(output_sink), + receiver_name, + ) + .await + } + + async fn receive_to_target( + self: &Arc, + ticket: String, + target: ReceiveTarget, + receiver_name: Option, + ) -> Result<()> { + let _permit = self + .transfer_slots + .acquire() + .await + .context("transfer limiter is closed")?; + let parsed = match parse_transfer_ticket_with_limits(&ticket, &self.limits) + .context("failed to parse transfer ticket") { Ok(parsed) => parsed, Err(error) => { @@ -415,32 +678,45 @@ impl CoreInner { .as_ref() .map(|metadata| metadata.transfer_id) .unwrap_or_else(unique_transfer_id); + self.persist_receive_start(transfer_id, &parsed, receiver_name.as_deref()) + .await?; // Cancellation is cooperative: it stops our receive future and marks // local state while lower-level Iroh work unwinds naturally. let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); - self.active_transfers - .lock() - .await - .insert(transfer_id, shutdown_tx); + self.active_transfers.lock().await.insert( + transfer_id, + ActiveTransfer { + direction: TransferDirection::Receive, + cancel: shutdown_tx, + }, + ); - let result = tokio::select! { - result = self.receive_inner(transfer_id, parsed, output_dir, receiver_name) => result, - _ = &mut shutdown_rx => Err(anyhow::anyhow!("transfer cancelled")), + let (result, cancelled) = tokio::select! { + result = self.receive_inner(transfer_id, parsed, target, receiver_name) => (result, false), + _ = &mut shutdown_rx => (Err(anyhow::anyhow!("transfer cancelled")), true), }; self.active_transfers.lock().await.remove(&transfer_id); if let Err(error) = &result { - self.emit_transfer( - transfer_id, - "receive", - "error", - "failed", - json!({ "reason": error.to_string() }), - ); - let _ = self - .repository - .update_transfer_status(transfer_id, STATUS_FAILED) - .await; + if cancelled { + self.emit_transfer(transfer_id, "receive", "lifecycle", "cancelled", json!({})); + } else { + self.emit_transfer( + transfer_id, + "receive", + "error", + "failed", + json!({ "reason": error.to_string() }), + ); + let _ = self + .repository + .transition_transfer_status( + transfer_id, + TransferStatus::Receiving, + TransferStatus::Failed, + ) + .await; + } } result } @@ -449,62 +725,31 @@ impl CoreInner { self: &Arc, transfer_id: u64, parsed: ParsedTransferTicket, - output_dir: PathBuf, + target: ReceiveTarget, receiver_name: Option, ) -> Result<()> { - let metadata_json = - serde_json::to_value(&parsed.metadata).unwrap_or(serde_json::Value::Null); - self.emit_transfer( - transfer_id, - "receive", - "lifecycle", - "started", - json!({ - "metadata": metadata_json, - "receiver_name": receiver_name, - }), - ); - self.repository - .upsert_transfer(TransferUpsert { - transfer_id, - direction: "receive", - status: STATUS_RECEIVING, - transfer_name: parsed - .metadata - .as_ref() - .map(|metadata| metadata.transfer_name.as_str()), - content_hash: parsed - .metadata - .as_ref() - .map(|metadata| metadata.content_hash.as_str()), - ticket: None, - file_count: parsed - .metadata - .as_ref() - .map(|metadata| metadata.file_count) - .unwrap_or_default(), - total_size: parsed - .metadata - .as_ref() - .map(|metadata| metadata.total_size) - .unwrap_or_default(), - }) - .await?; - tokio::fs::create_dir_all(&output_dir).await?; + if let ReceiveTarget::Directory(output_dir) = &target { + tokio::fs::create_dir_all(output_dir).await?; + } + let sender_addr = parsed.blob_ticket.addr().clone(); 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(), + let delivery_receipt = if let Some(metadata) = &parsed.metadata { + Some( + self.request_transfer_approval( + transfer_id, + sender_addr.clone(), + metadata, + receiver_name.as_deref(), + ) + .await?, ) - .await?; - } + } else { + None + }; let connection = self .endpoint - .connect(parsed.blob_ticket.addr().clone(), iroh_blobs::ALPN) + .connect(sender_addr.clone(), iroh_blobs::ALPN) .await?; self.emit_transfer(transfer_id, "receive", "network", "connected", json!({})); @@ -513,8 +758,23 @@ impl CoreInner { get_hash_seq_and_sizes(&connection, &hash_and_format.hash, 1024 * 1024 * 32, None) .await .context("failed to get file sizes")?; - let total_size = sizes.iter().copied().sum::(); + let total_size = sizes + .iter() + .try_fold(0u64, |total, size| total.checked_add(*size)) + .context("remote collection size overflow")?; let total_files = sizes.len().saturating_sub(1) as u64; + if total_files > self.limits.max_collection_files { + anyhow::bail!( + "remote collection has {total_files} files, limit is {}", + self.limits.max_collection_files + ); + } + if total_size > self.limits.max_total_bytes { + anyhow::bail!( + "remote collection size {total_size} exceeds limit {}", + self.limits.max_total_bytes + ); + } self.emit_transfer( transfer_id, "receive", @@ -542,56 +802,207 @@ impl CoreInner { } let collection = Collection::load(hash_and_format.hash, self.store.as_ref()).await?; - self.export_collection(transfer_id, total_files, output_dir, collection) + self.export_collection(transfer_id, total_files, target, collection) .await?; self.repository - .update_transfer_status(transfer_id, STATUS_DONE) + .transition_transfer_status( + transfer_id, + TransferStatus::Receiving, + TransferStatus::Done, + ) .await?; self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({})); + if let Some(receipt) = delivery_receipt { + let sender_transfer_id = receipt.transfer_id; + let client = HandshakeService::client(self.endpoint.clone(), sender_addr); + match client.report_delivery(receipt).await { + Ok(DeliveryReceiptResponse::Recorded) => self.emit_transfer( + transfer_id, + "receive", + "delivery", + "receipt-recorded", + json!({ "sender_transfer_id": sender_transfer_id }), + ), + Ok(DeliveryReceiptResponse::Rejected { reason }) => self.emit_transfer( + transfer_id, + "receive", + "delivery", + "receipt-rejected", + json!({ "reason": reason }), + ), + Err(error) => self.emit_transfer( + transfer_id, + "receive", + "delivery", + "receipt-failed", + json!({ "reason": error.to_string() }), + ), + } + } + Ok(()) + } + + async fn persist_receive_start( + &self, + transfer_id: u64, + parsed: &ParsedTransferTicket, + receiver_name: Option<&str>, + ) -> Result<()> { + let peer_id = parsed.blob_ticket.addr().id.to_string(); + self.repository + .start_receive(TransferUpsert { + transfer_id, + peer_id: Some(&peer_id), + direction: TransferDirection::Receive, + status: TransferStatus::Receiving, + transfer_name: parsed + .metadata + .as_ref() + .map(|metadata| metadata.transfer_name.as_str()), + content_hash: parsed + .metadata + .as_ref() + .map(|metadata| metadata.content_hash.as_str()), + ticket: None, + file_count: parsed + .metadata + .as_ref() + .map(|metadata| metadata.file_count) + .unwrap_or_default(), + total_size: parsed + .metadata + .as_ref() + .map(|metadata| metadata.total_size) + .unwrap_or_default(), + access_mode: mode_to_storage(&TransferAccessMode::ApprovalRequired), + }) + .await?; + let metadata_json = + serde_json::to_value(&parsed.metadata).unwrap_or(serde_json::Value::Null); + self.emit_transfer( + transfer_id, + "receive", + "lifecycle", + "started", + json!({ + "metadata": metadata_json, + "receiver_name": receiver_name, + }), + ); Ok(()) } async fn cancel_transfer(&self, transfer_id: u64) -> Result<()> { - if let Some(tx) = self.active_transfers.lock().await.remove(&transfer_id) { - let _ = tx.send(()); + let mut active_transfers = self.active_transfers.lock().await; + if let Some(direction) = active_transfers + .get(&transfer_id) + .map(|active| active.direction) + { + let expected = match direction { + TransferDirection::Send => TransferStatus::Importing, + TransferDirection::Receive => TransferStatus::Receiving, + }; + self.repository + .transition_transfer_status(transfer_id, expected, TransferStatus::Cancelled) + .await?; + let active = active_transfers + .remove(&transfer_id) + .ok_or_else(|| anyhow::anyhow!("active transfer registration disappeared"))?; + drop(active_transfers); + let _ = active.cancel.send(()); self.emit_transfer( transfer_id, - "app", + direction.as_str(), "lifecycle", "cancel-requested", json!({}), ); - self.repository - .update_transfer_status(transfer_id, STATUS_CANCELLED) - .await?; return Ok(()); } - if self - .active_shares - .lock() - .await - .remove(&transfer_id) - .is_some() - { + drop(active_transfers); + + let mut active_shares = self.active_shares.lock().await; + if active_shares.contains_key(&transfer_id) { + self.repository + .transition_transfer_status( + transfer_id, + TransferStatus::Sharing, + TransferStatus::Stopped, + ) + .await?; + active_shares.remove(&transfer_id); + drop(active_shares); self.hash_to_transfer .lock() .await .retain(|_, id| *id != transfer_id); self.access_policy.remove_transfer(transfer_id).await; - self.repository - .update_transfer_status(transfer_id, STATUS_STOPPED) - .await?; self.emit_transfer(transfer_id, "send", "lifecycle", "share-stopped", json!({})); return Ok(()); } anyhow::bail!("transfer not found") } + async fn delete_transfer(&self, transfer_id: u64) -> Result<()> { + if self + .active_transfers + .lock() + .await + .contains_key(&transfer_id) + { + anyhow::bail!("an active transfer must finish or be cancelled before deletion"); + } + + let transfer = self + .repository + .list_transfers() + .await? + .into_iter() + .find(|transfer| transfer.transfer_id == transfer_id) + .ok_or_else(|| anyhow::anyhow!("transfer not found"))?; + + // Revoke a live share durably before removing its history. If the + // subsequent delete fails, a restart must never expose it again. + if transfer.status == TransferStatus::Sharing.as_str() { + self.repository + .transition_transfer_status( + transfer_id, + TransferStatus::Sharing, + TransferStatus::Stopped, + ) + .await?; + } + + for request in self.repository.list_receiver_requests(transfer_id).await? { + if request.status == ReceiverRequestStatus::Requested.as_str() { + let _ = self + .approval + .respond( + request.id, + false, + Some("transfer deleted by sender".to_string()), + ) + .await; + } + } + + self.active_shares.lock().await.remove(&transfer_id); + self.hash_to_transfer + .lock() + .await + .retain(|_, id| *id != transfer_id); + self.access_policy.remove_transfer(transfer_id).await; + self.repository.delete_transfer(transfer_id).await + } + async fn set_transfer_access_mode( &self, transfer_id: u64, mode: TransferAccessMode, ) -> Result<()> { + self.repository + .update_active_share_access_mode(transfer_id, mode_to_storage(&mode)) + .await?; self.access_policy.set_mode(transfer_id, mode.clone()).await; self.emit_transfer( transfer_id, @@ -627,7 +1038,7 @@ impl CoreInner { addr: iroh::EndpointAddr, metadata: &TransferMetadata, receiver_name: Option<&str>, - ) -> Result<()> { + ) -> Result { self.emit_transfer( local_transfer_id, "receive", @@ -645,7 +1056,11 @@ impl CoreInner { .await .map_err(|error| anyhow::anyhow!("handshake request failed: {error}"))? { - HandshakeResponse::Approved { expires_at, .. } => { + HandshakeResponse::Approved { + request_id, + token, + expires_at, + } => { self.emit_transfer( local_transfer_id, "receive", @@ -656,7 +1071,11 @@ impl CoreInner { "expires_at": expires_at, }), ); - Ok(()) + Ok(DeliveryReceipt { + request_id, + transfer_id: metadata.transfer_id, + token, + }) } HandshakeResponse::Denied { reason } => { anyhow::bail!("transfer request was denied by sender: {reason}") @@ -691,7 +1110,7 @@ impl CoreInner { transfer_id: u64, sources: Vec, ) -> Result { - let files = collect_import_files(sources)?; + let files = collect_import_files_with_limits(sources, &self.limits)?; let default_name = default_collection_name(&files); let parallelism = num_cpus::get().min(8); let mut names_and_tags = n0_future::stream::iter(files) @@ -718,7 +1137,16 @@ impl CoreInner { .collect::>>()?; names_and_tags.sort_by(|(a, _, _), (b, _, _)| a.cmp(b)); - let total_size = names_and_tags.iter().map(|(_, _, size)| *size).sum::(); + let total_size = names_and_tags + .iter() + .try_fold(0u64, |total, (_, _, size)| total.checked_add(*size)) + .context("collection size overflow")?; + if total_size > self.limits.max_total_bytes { + anyhow::bail!( + "collection size {total_size} exceeds transfer limit {}", + self.limits.max_total_bytes + ); + } let (collection, tags) = names_and_tags .into_iter() .map(|(name, tag, _)| ((name, tag.hash()), tag)) @@ -757,7 +1185,15 @@ impl CoreInner { anyhow::bail!("import stream ended without a tag"); }; match item { - AddProgressItem::Size(item_size) => size = item_size, + AddProgressItem::Size(item_size) => { + if item_size > self.limits.max_total_bytes { + anyhow::bail!( + "file size {item_size} exceeds transfer limit {}", + self.limits.max_total_bytes + ); + } + size = item_size; + } AddProgressItem::CopyProgress(offset) => { self.emit_transfer( transfer_id, @@ -795,24 +1231,39 @@ impl CoreInner { &self, transfer_id: u64, total_files: u64, - output_dir: PathBuf, + target: ReceiveTarget, collection: Collection, ) -> Result<()> { for (i, (name, hash)) in collection.iter().enumerate() { - self.export_blob( - transfer_id, - total_files, - i as u64, - &output_dir, - name.as_ref(), - *hash, - ) - .await?; + match &target { + ReceiveTarget::Directory(output_dir) => { + self.export_blob_to_directory( + transfer_id, + total_files, + i as u64, + output_dir, + name.as_ref(), + *hash, + ) + .await?; + } + ReceiveTarget::OutputSink(output_sink) => { + self.export_blob_to_sink( + transfer_id, + total_files, + i as u64, + output_sink.as_ref(), + name.as_ref(), + *hash, + ) + .await?; + } + } } Ok(()) } - async fn export_blob( + async fn export_blob_to_directory( &self, transfer_id: u64, total_files: u64, @@ -821,13 +1272,14 @@ impl CoreInner { relative_path: &str, hash: Hash, ) -> Result<()> { - let target = safe_output_path(output_dir, relative_path)?; - if let Some(parent) = target.parent() { - tokio::fs::create_dir_all(parent).await?; + if relative_path.len() as u64 > self.limits.max_path_bytes { + anyhow::bail!( + "output path exceeds {} bytes: {relative_path}", + self.limits.max_path_bytes + ); } + let (pending_file, writer) = AtomicOutputFile::create(output_dir, relative_path)?; let (tx, rx) = async_channel::bounded::>>(2); - let writer = File::create(&target) - .with_context(|| format!("failed to create {}", target.display()))?; let writer_task = std::thread::spawn(move || write_stream_to_blocking_writer(writer, rx)); let mut stream = self.store.export_ranges(hash, 0..u64::MAX).stream(); let mut file_size = 0; @@ -869,7 +1321,65 @@ impl CoreInner { tx.send(Ok(None)) .await .map_err(|_| anyhow::anyhow!("export writer closed for {relative_path}"))?; - wait_for_writer(writer_task).await??; + let writer = wait_for_writer(writer_task).await??; + tokio::task::spawn_blocking(move || writer.sync_all()).await??; + pending_file.commit()?; + Ok(()) + } + + async fn export_blob_to_sink( + &self, + transfer_id: u64, + total_files: u64, + current_file_index: u64, + output_sink: &dyn ReceiveOutputSink, + relative_path: &str, + hash: Hash, + ) -> Result<()> { + if relative_path.len() as u64 > self.limits.max_path_bytes { + anyhow::bail!( + "output path exceeds {} bytes: {relative_path}", + self.limits.max_path_bytes + ); + } + let relative_path = validated_relative_string(relative_path)?; + let output_file = OutputSinkFile::start(output_sink, relative_path.clone())?; + let mut stream = self.store.export_ranges(hash, 0..u64::MAX).stream(); + let mut file_size = 0; + let mut exported = 0; + + while let Some(item) = stream.next().await { + match item { + ExportRangesItem::Size(size) => file_size = size, + ExportRangesItem::Data(leaf) => { + if leaf.offset != exported { + anyhow::bail!( + "export stream for {relative_path} yielded out-of-order data" + ); + } + exported += leaf.data.len() as u64; + output_file.write(leaf.data.to_vec())?; + self.emit_transfer( + transfer_id, + "receive", + "export", + "progress", + json!({ + "total_files": total_files, + "current_file_index": current_file_index, + "file_name": relative_path, + "file_size": file_size, + "exported": exported, + }), + ); + } + ExportRangesItem::Error(error) => { + anyhow::bail!("export failed for {relative_path}: {error}"); + } + } + } + + output_file.finish()?; Ok(()) } @@ -1166,6 +1676,8 @@ impl CoreInner { async fn list_events(&self, transfer_id: Option) -> Result> { self.event_hub.flush().await; - self.repository.list_events(transfer_id).await + self.repository + .list_events(transfer_id, self.limits.max_events) + .await } } diff --git a/crates/vnidrop/src/secret.rs b/crates/vnidrop/src/secret.rs index 546b051..3839c60 100644 --- a/crates/vnidrop/src/secret.rs +++ b/crates/vnidrop/src/secret.rs @@ -1,5 +1,8 @@ use std::{io, path::Path, str::FromStr}; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + use anyhow::{Context, Result}; use data_encoding::HEXLOWER; use iroh::SecretKey; @@ -18,13 +21,21 @@ pub(crate) async fn load_or_create_secret(app_data_dir: &Path) -> Result { let secret = SecretKey::generate(); tokio::fs::write(&path, HEXLOWER.encode(&secret.to_bytes())).await?; + restrict_permissions(&path).await?; Ok(secret) } Err(error) => Err(error.into()), } } + +async fn restrict_permissions(path: &Path) -> Result<()> { + #[cfg(unix)] + tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await?; + Ok(()) +} diff --git a/crates/vnidrop/src/tests.rs b/crates/vnidrop/src/tests.rs index 9672cd9..ba9f72a 100644 --- a/crates/vnidrop/src/tests.rs +++ b/crates/vnidrop/src/tests.rs @@ -1,406 +1,18 @@ -#[cfg(test)] -mod tests { - #[cfg(unix)] - use std::os::fd::AsRawFd; - use std::{io::Read, path::Path, sync::Arc}; - - use data_encoding::BASE64URL_NOPAD; - use iroh::SecretKey; - use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash}; - use serde_json::json; - - use crate::{ - access_policy::{AccessDecision, AccessPolicy}, - api::{CoreEvent, CoreEventSink, ShareSource, SourceKind, TransferMetadata}, - error::VnidropError, - filesystem::{ - collect_import_files, default_collection_name, path_to_string, - percent_decode_file_url_path, validated_relative_string, - }, - repository::{ReceiverRequestInsert, Repository}, - runtime::VnidropCore, - secret::load_or_create_secret, - ticket::{parse_transfer_ticket, VnidropTicket}, - TransferAccessMode, - }; - - struct TestSink; - - impl CoreEventSink for TestSink { - fn on_event(&self, _event: CoreEvent) {} - } - - #[test] - fn metadata_ticket_round_trips() { - let secret = SecretKey::generate(); - let addr = iroh::EndpointAddr::new(secret.public()); - let blob_ticket = BlobTicket::new(addr, Hash::new([7; 32]), BlobFormat::HashSeq); - let metadata = TransferMetadata::new( - 42, - "Summer photos", - Some("hammed".to_string()), - blob_ticket.hash(), - 3, - 2048, - ); - let encoded = VnidropTicket::new(blob_ticket.clone(), metadata.clone()) - .encode() - .unwrap(); - let parsed = parse_transfer_ticket(&encoded).unwrap(); - - assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash()); - assert_eq!( - parsed.metadata.unwrap().transfer_name, - metadata.transfer_name - ); - } - - #[test] - fn metadata_ticket_round_trip_tolerates_wrapped_whitespace() { - let secret = SecretKey::generate(); - let addr = iroh::EndpointAddr::new(secret.public()); - let blob_ticket = BlobTicket::new(addr, Hash::new([9; 32]), BlobFormat::HashSeq); - let metadata = TransferMetadata::new(7, "Wrapped", None, blob_ticket.hash(), 1, 10); - let encoded = VnidropTicket::new(blob_ticket.clone(), metadata) - .encode() - .unwrap(); - let wrapped = encoded - .as_bytes() - .chunks(8) - .map(|chunk| std::str::from_utf8(chunk).unwrap()) - .collect::>() - .join("\n "); - - let parsed = parse_transfer_ticket(&wrapped).unwrap(); - assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash()); - } - - #[test] - fn invalid_ticket_is_rejected() { - assert!(parse_transfer_ticket("not-a-ticket").is_err()); - } - - #[test] - fn ticket_rejects_unsupported_versions_and_mismatched_hashes() { - let secret = SecretKey::generate(); - let addr = iroh::EndpointAddr::new(secret.public()); - let blob_ticket = BlobTicket::new(addr, Hash::new([5; 32]), BlobFormat::HashSeq); - let payload = json!({ - "version": 2, - "blob_ticket": blob_ticket.to_string(), - "metadata": { - "version": 1, - "transfer_id": 7, - "transfer_name": "bad version", - "sender_name": null, - "created_at": 1, - "content_hash": blob_ticket.hash().to_string(), - "file_count": 1, - "total_size": 10 - } - }); - let encoded = format!( - "vnd1:{}", - BASE64URL_NOPAD.encode(payload.to_string().as_bytes()) - ); - assert!(parse_transfer_ticket(&encoded) - .unwrap_err() - .to_string() - .contains("unsupported VniDrop ticket version")); - - let payload = json!({ - "version": 1, - "blob_ticket": blob_ticket.to_string(), - "metadata": { - "version": 1, - "transfer_id": 7, - "transfer_name": "bad hash", - "sender_name": null, - "created_at": 1, - "content_hash": Hash::new([6; 32]).to_string(), - "file_count": 1, - "total_size": 10 - } - }); - let encoded = format!( - "vnd1:{}", - BASE64URL_NOPAD.encode(payload.to_string().as_bytes()) - ); - assert!(parse_transfer_ticket(&encoded) - .unwrap_err() - .to_string() - .contains("metadata hash does not match")); - } - - #[tokio::test] - async fn secret_persists() { - let temp = tempfile::tempdir().unwrap(); - let first = load_or_create_secret(temp.path()).await.unwrap(); - let second = load_or_create_secret(temp.path()).await.unwrap(); - assert_eq!(first.to_bytes(), second.to_bytes()); - } - - #[test] - fn path_validation_rejects_unsafe_paths() { - assert!(path_to_string(Path::new("../escape"), true).is_err()); - assert!(path_to_string(Path::new("/absolute"), true).is_err()); - assert!(validated_relative_string("bad\\name").is_err()); - assert!(validated_relative_string("").is_err()); - } - - #[test] - fn file_url_decodes_spaces() { - assert_eq!( - percent_decode_file_url_path("/tmp/My%20File.txt").unwrap(), - "/tmp/My File.txt" - ); - } - - #[cfg(unix)] - #[test] - fn file_descriptor_source_duplicates_and_streams() { - let mut temp = tempfile::tempfile().unwrap(); - std::io::Write::write_all(&mut temp, b"fd-backed import").unwrap(); - std::io::Seek::rewind(&mut temp).unwrap(); - - let files = collect_import_files(vec![ShareSource { - kind: SourceKind::FileDescriptor, - value: temp.as_raw_fd().to_string(), - display_name: Some("from-fd.txt".to_string()), - is_directory: false, - }]) - .unwrap(); - - let mut imported = files.into_iter().next().unwrap().source.open().unwrap(); - let mut content = String::new(); - imported.read_to_string(&mut content).unwrap(); - assert_eq!(content, "fd-backed import"); - } - - #[cfg(unix)] - #[test] - fn file_descriptor_source_rejects_invalid_values() { - assert!(collect_import_files(vec![ShareSource { - kind: SourceKind::FileDescriptor, - value: "not-an-fd".to_string(), - display_name: Some("from-fd.txt".to_string()), - is_directory: false, - }]) - .is_err()); - - assert!(collect_import_files(vec![ShareSource { - kind: SourceKind::FileDescriptor, - value: "-1".to_string(), - display_name: Some("from-fd.txt".to_string()), - is_directory: false, - }]) - .is_err()); - } - - #[test] - fn android_content_uri_must_be_opened_by_platform_code() { - let error = collect_import_files(vec![ShareSource { - kind: SourceKind::AndroidContentUri, - value: "content://media/item".to_string(), - display_name: Some("from-uri.txt".to_string()), - is_directory: false, - }]) - .unwrap_err() - .to_string(); - assert!(error.contains("ParcelFileDescriptor")); - } - - #[test] - fn directory_sources_preserve_safe_relative_names() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("picked"); - std::fs::create_dir_all(root.join("nested")).unwrap(); - std::fs::write(root.join("nested").join("a.txt"), b"a").unwrap(); - std::fs::write(root.join("b.txt"), b"b").unwrap(); - - let mut files = collect_import_files(vec![ShareSource { - kind: SourceKind::Path, - value: root.to_string_lossy().to_string(), - display_name: Some("Album".to_string()), - is_directory: true, - }]) - .unwrap(); - files.sort_by(|a, b| a.collection_name.cmp(&b.collection_name)); - - assert_eq!(default_collection_name(&files), "Album"); - assert_eq!(files[0].collection_name, "Album/b.txt"); - assert_eq!(files[1].collection_name, "Album/nested/a.txt"); - } - - #[test] - fn can_initialize_core() { - let temp = tempfile::tempdir().unwrap(); - let core = VnidropCore::initialize( - temp.path().to_string_lossy().to_string(), - Arc::new(TestSink), - ) - .unwrap(); - let status = core.status(); - assert!(!status.endpoint_id.is_empty()); - core.shutdown(); - } - - #[tokio::test] - async fn repository_persists_transfers_and_events() { - let temp = tempfile::tempdir().unwrap(); - let repository = Repository::open(temp.path()).await.unwrap(); - assert_eq!(repository.schema_version().await.unwrap(), 1); - repository - .upsert_transfer(crate::repository::TransferUpsert { - transfer_id: 7, - direction: "send", - status: "sharing", - transfer_name: Some("demo"), - content_hash: Some("hash"), - ticket: Some("ticket"), - file_count: 1, - total_size: 12, - }) - .await - .unwrap(); - repository - .insert_event(&CoreEvent { - id: "event-1".to_string(), - timestamp: 10, - scope: "transfer".to_string(), - transfer_id: Some(7), - direction: Some("send".to_string()), - phase: "ticket".to_string(), - kind: "created".to_string(), - data_json: "{}".to_string(), - }) - .await - .unwrap(); - - let transfers = repository.list_transfers().await.unwrap(); - assert_eq!(transfers.len(), 1); - assert_eq!(transfers[0].transfer_name.as_deref(), Some("demo")); - - let events = repository.list_events(Some(7)).await.unwrap(); - assert_eq!(events.len(), 1); - assert_eq!(events[0].kind, "created"); - - let reopened = Repository::open(temp.path()).await.unwrap(); - let transfers = reopened.list_transfers().await.unwrap(); - assert_eq!(transfers.len(), 1); - let events = reopened.list_events(Some(7)).await.unwrap(); - 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(); - policy - .set_mode(99, TransferAccessMode::ApprovalRequired) - .await; - - assert_eq!( - policy.decide(99, Some("node-a")).await, - AccessDecision::Deny { - reason: "approval-required" - } - ); - - policy.approve_endpoint(99, "node-a".to_string()).await; - assert_eq!( - policy.decide(99, Some("node-a")).await, - AccessDecision::Allow - ); - assert_eq!( - policy.decide(99, None).await, - AccessDecision::Deny { - reason: "missing-endpoint-id" - } - ); - } - - #[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(); - let core = VnidropCore::initialize( - temp.path().to_string_lossy().to_string(), - Arc::new(TestSink), - ) - .unwrap(); - - let error = core - .receive( - "not-a-ticket".to_string(), - temp.path().to_string_lossy().to_string(), - None, - ) - .unwrap_err(); - assert!(matches!(error, VnidropError::Ticket { .. })); - - let events = core.list_events(None).unwrap(); - assert!(events - .iter() - .any(|event| event.phase == "error" && event.kind == "invalid-ticket")); - core.shutdown(); - } -} +#[path = "tests/access_policy.rs"] +mod access_policy_tests; +#[path = "tests/filesystem.rs"] +mod filesystem_tests; +#[path = "tests/handshake.rs"] +mod handshake_tests; +#[path = "tests/limits.rs"] +mod limits_tests; +#[path = "tests/repository.rs"] +mod repository_tests; +#[path = "tests/runtime.rs"] +mod runtime_tests; +#[path = "tests/secret.rs"] +mod secret_tests; +#[path = "tests/ticket.rs"] +mod ticket_tests; +#[path = "tests/transfer_state.rs"] +mod transfer_state_tests; diff --git a/crates/vnidrop/src/tests/access_policy.rs b/crates/vnidrop/src/tests/access_policy.rs new file mode 100644 index 0000000..4aa6fcc --- /dev/null +++ b/crates/vnidrop/src/tests/access_policy.rs @@ -0,0 +1,56 @@ +use crate::{ + access_policy::{AccessDecision, AccessPolicy}, + util::now_ms, + TransferAccessMode, +}; + +#[tokio::test] +async fn requires_approved_endpoint_when_locked() { + let policy = AccessPolicy::new(); + policy + .set_mode(99, TransferAccessMode::ApprovalRequired) + .await; + + assert_eq!( + policy.decide(99, Some("node-a")).await, + AccessDecision::Deny { + reason: "approval-required" + } + ); + + policy.approve_endpoint(99, "node-a".to_string()).await; + assert_eq!( + policy.decide(99, Some("node-a")).await, + AccessDecision::Allow + ); + assert_eq!( + policy.decide(99, None).await, + AccessDecision::Deny { + reason: "missing-endpoint-id" + } + ); +} + +#[tokio::test] +async fn 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(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" + } + ); +} diff --git a/crates/vnidrop/src/tests/filesystem.rs b/crates/vnidrop/src/tests/filesystem.rs new file mode 100644 index 0000000..38c9937 --- /dev/null +++ b/crates/vnidrop/src/tests/filesystem.rs @@ -0,0 +1,233 @@ +#[cfg(unix)] +use std::os::fd::AsRawFd; +use std::{io::Read, path::Path}; + +use crate::{ + api::{CoreLimits, ShareSource, SourceKind}, + filesystem::{ + cleanup_stale_temporary_files, collect_import_files, collect_import_files_with_limits, + default_collection_name, path_to_string, percent_decode_file_url_path, + validated_relative_string, AtomicOutputFile, + }, +}; + +#[test] +fn path_validation_rejects_unsafe_paths() { + assert!(path_to_string(Path::new("../escape"), true).is_err()); + assert!(path_to_string(Path::new("/absolute"), true).is_err()); + assert!(validated_relative_string("bad\\name").is_err()); + assert!(validated_relative_string("").is_err()); +} + +#[test] +fn file_url_decodes_spaces() { + assert_eq!( + percent_decode_file_url_path("/tmp/My%20File.txt").unwrap(), + "/tmp/My File.txt" + ); +} + +#[cfg(unix)] +#[test] +fn file_descriptor_source_duplicates_and_streams() { + let mut temp = tempfile::tempfile().unwrap(); + std::io::Write::write_all(&mut temp, b"fd-backed import").unwrap(); + std::io::Seek::rewind(&mut temp).unwrap(); + + let files = collect_import_files(vec![ShareSource { + kind: SourceKind::FileDescriptor, + value: temp.as_raw_fd().to_string(), + display_name: Some("from-fd.txt".to_string()), + is_directory: false, + }]) + .unwrap(); + + let mut imported = files.into_iter().next().unwrap().source.open().unwrap(); + let mut content = String::new(); + imported.read_to_string(&mut content).unwrap(); + assert_eq!(content, "fd-backed import"); +} + +#[cfg(unix)] +#[test] +fn file_descriptor_source_rejects_invalid_values() { + for value in ["not-an-fd", "-1"] { + assert!(collect_import_files(vec![ShareSource { + kind: SourceKind::FileDescriptor, + value: value.to_string(), + display_name: Some("from-fd.txt".to_string()), + is_directory: false, + }]) + .is_err()); + } +} + +#[test] +fn android_content_uri_must_be_opened_by_platform_code() { + let error = collect_import_files(vec![ShareSource { + kind: SourceKind::AndroidContentUri, + value: "content://media/item".to_string(), + display_name: Some("from-uri.txt".to_string()), + is_directory: false, + }]) + .unwrap_err() + .to_string(); + assert!(error.contains("ParcelFileDescriptor")); +} + +#[test] +fn directory_sources_preserve_safe_relative_names() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("picked"); + std::fs::create_dir_all(root.join("nested")).unwrap(); + std::fs::write(root.join("nested").join("a.txt"), b"a").unwrap(); + std::fs::write(root.join("b.txt"), b"b").unwrap(); + + let mut files = collect_import_files(vec![ShareSource { + kind: SourceKind::Path, + value: root.to_string_lossy().to_string(), + display_name: Some("Album".to_string()), + is_directory: true, + }]) + .unwrap(); + files.sort_by(|a, b| a.collection_name.cmp(&b.collection_name)); + + assert_eq!(default_collection_name(&files), "Album"); + assert_eq!(files[0].collection_name, "Album/b.txt"); + assert_eq!(files[1].collection_name, "Album/nested/a.txt"); +} + +#[test] +fn atomic_output_commits_without_overwriting() { + let output = tempfile::tempdir().unwrap(); + let (pending, mut file) = AtomicOutputFile::create(output.path(), "nested/file.txt").unwrap(); + std::io::Write::write_all(&mut file, b"complete").unwrap(); + file.sync_all().unwrap(); + drop(file); + pending.commit().unwrap(); + + assert_eq!( + std::fs::read(output.path().join("nested/file.txt")).unwrap(), + b"complete" + ); + assert!(AtomicOutputFile::create(output.path(), "nested/file.txt").is_err()); + assert_eq!( + std::fs::read(output.path().join("nested/file.txt")).unwrap(), + b"complete" + ); +} + +#[test] +fn dropped_atomic_output_removes_partial_file() { + let output = tempfile::tempdir().unwrap(); + let (pending, mut file) = AtomicOutputFile::create(output.path(), "partial.txt").unwrap(); + std::io::Write::write_all(&mut file, b"partial").unwrap(); + drop(file); + drop(pending); + + assert!(!output.path().join("partial.txt").exists()); + assert!(std::fs::read_dir(output.path()).unwrap().all(|entry| !entry + .unwrap() + .file_name() + .to_string_lossy() + .contains(".part"))); +} + +#[cfg(unix)] +#[test] +fn atomic_output_rejects_symlinked_parent() { + use std::os::unix::fs::symlink; + + let output = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + symlink(outside.path(), output.path().join("link")).unwrap(); + + assert!(AtomicOutputFile::create(output.path(), "link/escape.txt").is_err()); + assert!(!outside.path().join("escape.txt").exists()); +} + +#[cfg(unix)] +#[test] +fn atomic_output_never_replaces_symlink_destination() { + use std::os::unix::fs::symlink; + + let output = tempfile::tempdir().unwrap(); + let outside = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(outside.path(), b"outside").unwrap(); + symlink(outside.path(), output.path().join("target.txt")).unwrap(); + + assert!(AtomicOutputFile::create(output.path(), "target.txt").is_err()); + assert_eq!(std::fs::read(outside.path()).unwrap(), b"outside"); +} + +#[test] +fn cleanup_removes_only_vnidrop_temporary_files() { + let output = tempfile::tempdir().unwrap(); + std::fs::write(output.path().join(".file.vnidrop-old.part"), b"partial").unwrap(); + std::fs::write(output.path().join("keep.part"), b"keep").unwrap(); + + assert_eq!( + cleanup_stale_temporary_files(output.path(), std::time::Duration::ZERO).unwrap(), + 1 + ); + assert!(!output.path().join(".file.vnidrop-old.part").exists()); + assert!(output.path().join("keep.part").exists()); +} + +#[test] +fn import_collection_limits_are_enforced_before_streaming() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("one.txt"), b"one").unwrap(); + std::fs::write(temp.path().join("two.txt"), b"two").unwrap(); + let source = ShareSource { + kind: SourceKind::Path, + value: temp.path().to_string_lossy().to_string(), + display_name: Some("folder".to_string()), + is_directory: true, + }; + + let file_limits = CoreLimits { + max_collection_files: 1, + ..CoreLimits::default() + }; + assert!(collect_import_files_with_limits(vec![source.clone()], &file_limits).is_err()); + + let size_limits = CoreLimits { + max_collection_files: 10, + max_total_bytes: 5, + ..CoreLimits::default() + }; + assert!(collect_import_files_with_limits(vec![source], &size_limits).is_err()); + + let path_limits = CoreLimits { + max_path_bytes: 4, + ..CoreLimits::default() + }; + let file = temp.path().join("long-name.txt"); + std::fs::write(&file, b"x").unwrap(); + assert!(collect_import_files_with_limits( + vec![ShareSource { + kind: SourceKind::Path, + value: file.to_string_lossy().to_string(), + display_name: Some("long-name.txt".to_string()), + is_directory: false, + }], + &path_limits, + ) + .is_err()); +} + +#[test] +fn generated_relative_paths_never_accept_traversal_components() { + for prefix in ["", "folder/", "a/b/"] { + for traversal in ["..", "../escape", "..\\escape"] { + let candidate = format!("{prefix}{traversal}"); + assert!( + validated_relative_string(&candidate).is_err(), + "{candidate}" + ); + } + } + assert!(validated_relative_string(".").is_err()); + assert!(validated_relative_string("/absolute").is_err()); +} diff --git a/crates/vnidrop/src/tests/handshake.rs b/crates/vnidrop/src/tests/handshake.rs new file mode 100644 index 0000000..7be2cbb --- /dev/null +++ b/crates/vnidrop/src/tests/handshake.rs @@ -0,0 +1,13 @@ +use crate::handshake::HandshakeResponse; + +#[test] +fn malformed_handshake_response_is_rejected() { + for payload in [ + r#"{"Approved":{"token":7,"expires_at":"later"}}"#, + r#"{"Denied":{}}"#, + r#"{"Unknown":{"reason":"no"}}"#, + "not-json", + ] { + assert!(serde_json::from_str::(payload).is_err()); + } +} diff --git a/crates/vnidrop/src/tests/limits.rs b/crates/vnidrop/src/tests/limits.rs new file mode 100644 index 0000000..0733c3c --- /dev/null +++ b/crates/vnidrop/src/tests/limits.rs @@ -0,0 +1,15 @@ +use crate::api::CoreLimits; + +#[test] +fn default_limits_are_valid() { + CoreLimits::default().validate().unwrap(); +} + +#[test] +fn zero_limit_is_rejected() { + let limits = CoreLimits { + max_sources: 0, + ..CoreLimits::default() + }; + assert!(limits.validate().is_err()); +} diff --git a/crates/vnidrop/src/tests/repository.rs b/crates/vnidrop/src/tests/repository.rs new file mode 100644 index 0000000..1272591 --- /dev/null +++ b/crates/vnidrop/src/tests/repository.rs @@ -0,0 +1,590 @@ +use crate::{ + api::CoreEvent, + repository::{ReceiverRequestInsert, Repository, TransferUpsert}, + transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus}, +}; + +fn transfer( + transfer_id: u64, + direction: TransferDirection, + status: TransferStatus, +) -> TransferUpsert<'static> { + TransferUpsert { + transfer_id, + peer_id: None, + direction, + status, + transfer_name: Some("demo"), + content_hash: Some("hash"), + ticket: Some("ticket"), + file_count: 1, + total_size: 12, + access_mode: "approval_required", + } +} + +#[tokio::test] +async fn persists_transfers_and_events_across_reopen() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + assert_eq!(repository.schema_version().await.unwrap(), 4); + repository + .insert_transfer(transfer( + 7, + TransferDirection::Send, + TransferStatus::Sharing, + )) + .await + .unwrap(); + + let shares = repository.list_active_shares().await.unwrap(); + assert_eq!(shares.len(), 1); + assert_eq!(shares[0].transfer_id, 7); + assert_eq!(shares[0].content_hash, "hash"); + assert_eq!(shares[0].access_mode, "approval_required"); + + repository + .insert_event( + &CoreEvent { + id: "event-1".to_string(), + timestamp: 10, + scope: "transfer".to_string(), + transfer_id: Some(7), + direction: Some("send".to_string()), + phase: "ticket".to_string(), + kind: "created".to_string(), + data_json: "{}".to_string(), + }, + 500, + ) + .await + .unwrap(); + + let transfers = repository.list_transfers().await.unwrap(); + assert_eq!(transfers.len(), 1); + assert_eq!(transfers[0].transfer_name.as_deref(), Some("demo")); + + let events = repository.list_events(Some(7), 500).await.unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].kind, "created"); + + drop(repository); + let reopened = Repository::open(temp.path()).await.unwrap(); + let transfers = reopened.list_transfers().await.unwrap(); + assert_eq!(transfers.len(), 1); + let events = reopened.list_events(Some(7), 500).await.unwrap(); + assert_eq!(events[0].id, "event-1"); +} + +#[tokio::test] +async fn receiver_request_can_only_be_resolved_once() { + 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", ReceiverRequestStatus::Accepted, None) + .await + .unwrap(); + repository + .set_receiver_receipt_token("request-1", "token-hash") + .await + .unwrap(); + repository + .complete_receiver_delivery("request-1", 77, "node-a", "token-hash") + .await + .unwrap(); + repository + .complete_receiver_delivery("request-1", 77, "node-a", "token-hash") + .await + .unwrap(); + assert!(repository + .complete_receiver_delivery("request-1", 77, "node-b", "token-hash") + .await + .is_err()); + + assert!(repository + .update_receiver_request_status("request-1", ReceiverRequestStatus::Refused, Some("late"),) + .await + .is_err()); + assert!(repository + .update_receiver_request_status("missing", ReceiverRequestStatus::Accepted, None) + .await + .is_err()); + + let requests = repository.list_receiver_requests(77).await.unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].status, "completed"); + assert_eq!(requests[0].receiver_name.as_deref(), Some("receiver")); + assert!(requests[0].responded_at.is_some()); + assert!(requests[0].completed_at.is_some()); +} + +#[tokio::test] +async fn startup_expiration_is_idempotent_for_pending_requests() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_receiver_request(ReceiverRequestInsert { + id: "pending-1", + transfer_id: 78, + remote_endpoint_id: "node-a", + transfer_name: "demo", + receiver_name: None, + receiver_device_name: None, + app_version: "0.1.0", + }) + .await + .unwrap(); + + assert_eq!( + repository + .expire_pending_receiver_requests("restart") + .await + .unwrap(), + 1 + ); + assert_eq!( + repository + .expire_pending_receiver_requests("restart") + .await + .unwrap(), + 0 + ); + let requests = repository.list_receiver_requests(78).await.unwrap(); + assert_eq!(requests[0].status, "expired"); + assert_eq!(requests[0].reason.as_deref(), Some("restart")); +} + +#[tokio::test] +async fn concurrent_approval_responses_have_single_winner() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_receiver_request(ReceiverRequestInsert { + id: "race-1", + transfer_id: 79, + remote_endpoint_id: "node-a", + transfer_name: "demo", + receiver_name: None, + receiver_device_name: None, + app_version: "0.1.0", + }) + .await + .unwrap(); + + let accepted_repository = repository.clone(); + let refused_repository = repository.clone(); + let (accepted, refused) = tokio::join!( + accepted_repository.update_receiver_request_status( + "race-1", + ReceiverRequestStatus::Accepted, + None, + ), + refused_repository.update_receiver_request_status( + "race-1", + ReceiverRequestStatus::Refused, + Some("race"), + ), + ); + assert_ne!(accepted.is_ok(), refused.is_ok()); + let requests = repository.list_receiver_requests(79).await.unwrap(); + assert!(matches!( + requests[0].status.as_str(), + "accepted" | "refused" + )); +} + +#[tokio::test] +async fn conditional_transition_rejects_stale_state() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_transfer(transfer( + 81, + TransferDirection::Send, + TransferStatus::Sharing, + )) + .await + .unwrap(); + + let error = repository + .transition_transfer_status(81, TransferStatus::Receiving, TransferStatus::Done) + .await + .unwrap_err(); + assert!(error.to_string().contains("expected one matching transfer")); + assert_eq!( + repository.list_transfers().await.unwrap()[0].status, + "sharing" + ); +} + +#[tokio::test] +async fn repeated_terminal_transition_is_idempotent() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_transfer(transfer( + 88, + TransferDirection::Send, + TransferStatus::Importing, + )) + .await + .unwrap(); + + repository + .transition_transfer_status(88, TransferStatus::Importing, TransferStatus::Failed) + .await + .unwrap(); + repository + .transition_transfer_status(88, TransferStatus::Importing, TransferStatus::Failed) + .await + .unwrap(); + assert_eq!( + repository.list_transfers().await.unwrap()[0].status, + "failed" + ); +} + +#[tokio::test] +async fn duplicate_transfer_does_not_overwrite_existing_record() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_transfer(transfer( + 82, + TransferDirection::Send, + TransferStatus::Sharing, + )) + .await + .unwrap(); + + assert!(repository + .insert_transfer(transfer( + 82, + TransferDirection::Receive, + TransferStatus::Receiving, + )) + .await + .is_err()); + let stored = repository.list_transfers().await.unwrap().remove(0); + assert_eq!(stored.direction, "send"); + assert_eq!(stored.status, "sharing"); +} + +#[tokio::test] +async fn recovery_fails_only_interrupted_states() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_transfer(transfer( + 83, + TransferDirection::Send, + TransferStatus::Importing, + )) + .await + .unwrap(); + repository + .insert_transfer(transfer( + 84, + TransferDirection::Receive, + TransferStatus::Receiving, + )) + .await + .unwrap(); + repository + .insert_transfer(transfer( + 85, + TransferDirection::Send, + TransferStatus::Sharing, + )) + .await + .unwrap(); + + let recovered = repository.recover_interrupted_transfers().await.unwrap(); + assert_eq!(recovered.len(), 2); + assert_eq!(recovered[0].transfer_id, 83); + assert_eq!(recovered[0].previous_status, TransferStatus::Importing); + assert_eq!(recovered[1].transfer_id, 84); + assert_eq!(recovered[1].previous_status, TransferStatus::Receiving); + + let transfers = repository.list_transfers().await.unwrap(); + assert_eq!( + transfers + .iter() + .find(|transfer| transfer.transfer_id == 83) + .unwrap() + .status, + "failed" + ); + assert_eq!( + transfers + .iter() + .find(|transfer| transfer.transfer_id == 84) + .unwrap() + .status, + "failed" + ); + assert_eq!( + transfers + .iter() + .find(|transfer| transfer.transfer_id == 85) + .unwrap() + .status, + "sharing" + ); + assert!(repository + .recover_interrupted_transfers() + .await + .unwrap() + .is_empty()); +} + +#[tokio::test] +async fn share_completion_is_conditional_and_atomic() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_transfer(TransferUpsert { + transfer_id: 86, + peer_id: None, + direction: TransferDirection::Send, + status: TransferStatus::Importing, + transfer_name: Some("pending"), + content_hash: None, + ticket: None, + file_count: 0, + total_size: 0, + access_mode: "approval_required", + }) + .await + .unwrap(); + + repository + .complete_share_import(TransferUpsert { + transfer_id: 86, + peer_id: None, + direction: TransferDirection::Send, + status: TransferStatus::Sharing, + transfer_name: Some("complete"), + content_hash: Some("final-hash"), + ticket: Some("final-ticket"), + file_count: 2, + total_size: 24, + access_mode: "approval_required", + }) + .await + .unwrap(); + + let stored = repository.list_transfers().await.unwrap().remove(0); + assert_eq!(stored.status, "sharing"); + assert_eq!(stored.transfer_name.as_deref(), Some("complete")); + assert_eq!(stored.content_hash.as_deref(), Some("final-hash")); + assert_eq!(stored.ticket.as_deref(), Some("final-ticket")); + assert_eq!(stored.file_count, 2); + assert_eq!(stored.total_size, 24); + + assert!(repository + .complete_share_import(transfer( + 86, + TransferDirection::Send, + TransferStatus::Sharing, + )) + .await + .is_err()); +} + +#[tokio::test] +async fn injected_write_failure_preserves_previous_state() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_transfer(TransferUpsert { + transfer_id: 87, + peer_id: None, + direction: TransferDirection::Send, + status: TransferStatus::Importing, + transfer_name: Some("pending"), + content_hash: None, + ticket: None, + file_count: 0, + total_size: 0, + access_mode: "approval_required", + }) + .await + .unwrap(); + repository.fail_next_write(); + + assert!(repository + .complete_share_import(transfer( + 87, + TransferDirection::Send, + TransferStatus::Sharing, + )) + .await + .is_err()); + let stored = repository.list_transfers().await.unwrap().remove(0); + assert_eq!(stored.status, "importing"); + assert_eq!(stored.content_hash, None); + assert_eq!(stored.ticket, None); +} + +#[tokio::test] +async fn migrates_schema_v2_identity_without_losing_transfer() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("vnidrop.sqlite3"); + let options = SqliteConnectOptions::from_str("sqlite://") + .unwrap() + .filename(&database) + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .unwrap(); + sqlx::query( + r#" + CREATE TABLE transfers ( + transfer_id INTEGER PRIMARY KEY, + direction TEXT NOT NULL, + status TEXT NOT NULL, + transfer_name TEXT, + content_hash TEXT, + ticket TEXT, + file_count INTEGER NOT NULL DEFAULT 0, + total_size INTEGER NOT NULL DEFAULT 0, + access_mode TEXT NOT NULL DEFAULT 'approval_required', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + "#, + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + r#" + INSERT INTO transfers ( + transfer_id, direction, status, transfer_name, content_hash, ticket, + file_count, total_size, access_mode, created_at, updated_at + ) VALUES (7, 'send', 'stopped', 'legacy', 'hash', 'ticket', 1, 12, + 'approval_required', 10, 11) + "#, + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query("PRAGMA user_version = 2") + .execute(&pool) + .await + .unwrap(); + pool.close().await; + + let repository = Repository::open(temp.path()).await.unwrap(); + assert_eq!(repository.schema_version().await.unwrap(), 4); + let stored = repository.list_transfers().await.unwrap().remove(0); + assert_eq!(stored.transfer_id, 7); + assert_eq!(stored.local_id, "legacy-7-send"); + assert_eq!(stored.transfer_name.as_deref(), Some("legacy")); + assert_eq!(stored.ticket.as_deref(), Some("ticket")); + assert_eq!(stored.peer_id, None); +} + +#[tokio::test] +async fn event_reads_respect_configured_history_limit() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + for sequence in 0..3 { + repository + .insert_event( + &CoreEvent { + id: format!("event-{sequence}"), + timestamp: sequence, + scope: "endpoint".to_string(), + transfer_id: None, + direction: None, + phase: "test".to_string(), + kind: "generated".to_string(), + data_json: "{}".to_string(), + }, + 2, + ) + .await + .unwrap(); + } + + let events = repository.list_events(None, 2).await.unwrap(); + assert_eq!(events.len(), 2); + assert_eq!(events[0].id, "event-2"); + assert_eq!(events[1].id, "event-1"); +} + +#[tokio::test] +async fn deleting_transfer_removes_related_history_transactionally() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_transfer(transfer( + 88, + TransferDirection::Send, + TransferStatus::Stopped, + )) + .await + .unwrap(); + repository + .insert_receiver_request(ReceiverRequestInsert { + id: "request-delete", + transfer_id: 88, + remote_endpoint_id: "receiver", + transfer_name: "demo", + receiver_name: None, + receiver_device_name: None, + app_version: "1.0", + }) + .await + .unwrap(); + repository + .insert_event( + &CoreEvent { + id: "event-delete".to_string(), + timestamp: 1, + scope: "transfer".to_string(), + transfer_id: Some(88), + direction: Some("send".to_string()), + phase: "test".to_string(), + kind: "created".to_string(), + data_json: "{}".to_string(), + }, + 500, + ) + .await + .unwrap(); + + repository.delete_transfer(88).await.unwrap(); + + assert!(repository.list_transfers().await.unwrap().is_empty()); + assert!(repository + .list_events(Some(88), 500) + .await + .unwrap() + .is_empty()); + assert!(repository + .list_receiver_requests(88) + .await + .unwrap() + .is_empty()); + assert!(repository.delete_transfer(88).await.is_err()); +} +use std::str::FromStr; + +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; diff --git a/crates/vnidrop/src/tests/runtime.rs b/crates/vnidrop/src/tests/runtime.rs new file mode 100644 index 0000000..6591a97 --- /dev/null +++ b/crates/vnidrop/src/tests/runtime.rs @@ -0,0 +1,143 @@ +use std::sync::Arc; + +use iroh_blobs::Hash; + +use crate::{ + repository::{Repository, TransferUpsert}, + transfer_state::{TransferDirection, TransferStatus}, + CoreEvent, CoreEventSink, VnidropCore, VnidropError, +}; + +struct TestSink; + +impl CoreEventSink for TestSink { + fn on_event(&self, _event: CoreEvent) {} +} + +#[test] +fn initializes_and_reports_endpoint() { + let temp = tempfile::tempdir().unwrap(); + let core = VnidropCore::initialize( + temp.path().to_string_lossy().to_string(), + Arc::new(TestSink), + ) + .unwrap(); + + assert!(!core.status().endpoint_id.is_empty()); + core.shutdown(); +} + +#[test] +fn invalid_receive_ticket_is_typed_and_persisted_as_event() { + let temp = tempfile::tempdir().unwrap(); + let core = VnidropCore::initialize( + temp.path().to_string_lossy().to_string(), + Arc::new(TestSink), + ) + .unwrap(); + + let error = core + .receive( + "not-a-ticket".to_string(), + temp.path().to_string_lossy().to_string(), + None, + ) + .unwrap_err(); + assert!(matches!(error, VnidropError::Ticket { .. })); + + let events = core.list_events(None).unwrap(); + assert!(events + .iter() + .any(|event| event.phase == "error" && event.kind == "invalid-ticket")); + core.shutdown(); +} + +#[test] +fn startup_recovers_interrupted_transfer_and_persists_event() { + let temp = tempfile::tempdir().unwrap(); + let preparation_runtime = tokio::runtime::Runtime::new().unwrap(); + preparation_runtime.block_on(async { + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_transfer(TransferUpsert { + transfer_id: 91, + peer_id: None, + direction: TransferDirection::Receive, + status: TransferStatus::Receiving, + transfer_name: Some("interrupted"), + content_hash: Some("hash"), + ticket: None, + file_count: 1, + total_size: 5, + access_mode: "approval_required", + }) + .await + .unwrap(); + }); + drop(preparation_runtime); + + let core = VnidropCore::initialize( + temp.path().to_string_lossy().to_string(), + Arc::new(TestSink), + ) + .unwrap(); + let transfer = core + .list_transfers() + .unwrap() + .into_iter() + .find(|transfer| transfer.transfer_id == 91) + .unwrap(); + assert_eq!(transfer.status, "failed"); + + let events = core.list_events(Some(91)).unwrap(); + assert!(events.iter().any(|event| { + event.phase == "recovery" + && event.kind == "interrupted-transfer-failed" + && event.data_json.contains("receiving") + })); + core.shutdown(); +} + +#[test] +fn startup_fails_persisted_share_when_root_blob_is_missing() { + let temp = tempfile::tempdir().unwrap(); + let preparation_runtime = tokio::runtime::Runtime::new().unwrap(); + preparation_runtime.block_on(async { + let repository = Repository::open(temp.path()).await.unwrap(); + let missing_hash = Hash::new([42; 32]).to_string(); + repository + .insert_transfer(TransferUpsert { + transfer_id: 92, + peer_id: None, + direction: TransferDirection::Send, + status: TransferStatus::Sharing, + transfer_name: Some("missing blob"), + content_hash: Some(&missing_hash), + ticket: Some("ticket"), + file_count: 1, + total_size: 5, + access_mode: "approval_required", + }) + .await + .unwrap(); + }); + drop(preparation_runtime); + + let core = VnidropCore::initialize( + temp.path().to_string_lossy().to_string(), + Arc::new(TestSink), + ) + .unwrap(); + let transfer = core + .list_transfers() + .unwrap() + .into_iter() + .find(|transfer| transfer.transfer_id == 92) + .unwrap(); + assert_eq!(transfer.status, "failed"); + assert_eq!(core.status().active_shares, 0); + assert!(core.list_events(Some(92)).unwrap().iter().any(|event| { + event.phase == "recovery" && event.kind == "share-root-missing-or-corrupt" + })); + core.shutdown(); +} diff --git a/crates/vnidrop/src/tests/secret.rs b/crates/vnidrop/src/tests/secret.rs new file mode 100644 index 0000000..20b6bcd --- /dev/null +++ b/crates/vnidrop/src/tests/secret.rs @@ -0,0 +1,22 @@ +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +use crate::secret::load_or_create_secret; + +#[tokio::test] +async fn persists_with_restricted_permissions() { + let temp = tempfile::tempdir().unwrap(); + let first = load_or_create_secret(temp.path()).await.unwrap(); + let second = load_or_create_secret(temp.path()).await.unwrap(); + assert_eq!(first.to_bytes(), second.to_bytes()); + + #[cfg(unix)] + assert_eq!( + std::fs::metadata(temp.path().join("iroh.secret")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); +} diff --git a/crates/vnidrop/src/tests/ticket.rs b/crates/vnidrop/src/tests/ticket.rs new file mode 100644 index 0000000..62ace59 --- /dev/null +++ b/crates/vnidrop/src/tests/ticket.rs @@ -0,0 +1,134 @@ +use data_encoding::BASE64URL_NOPAD; +use iroh::SecretKey; +use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash}; +use serde_json::json; + +use crate::{ + api::{CoreLimits, TransferMetadata}, + ticket::{parse_transfer_ticket, parse_transfer_ticket_with_limits, VnidropTicket}, +}; + +fn blob_ticket(hash_byte: u8) -> BlobTicket { + let secret = SecretKey::generate(); + let addr = iroh::EndpointAddr::new(secret.public()); + BlobTicket::new(addr, Hash::new([hash_byte; 32]), BlobFormat::HashSeq) +} + +#[test] +fn metadata_ticket_round_trips() { + let blob_ticket = blob_ticket(7); + let metadata = TransferMetadata::new( + 42, + "Summer photos", + Some("hammed".to_string()), + blob_ticket.hash(), + 3, + 2048, + ); + let encoded = VnidropTicket::new(blob_ticket.clone(), metadata.clone()) + .encode() + .unwrap(); + let parsed = parse_transfer_ticket(&encoded).unwrap(); + + assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash()); + assert_eq!( + parsed.metadata.unwrap().transfer_name, + metadata.transfer_name + ); +} + +#[test] +fn metadata_ticket_tolerates_wrapped_whitespace() { + let blob_ticket = blob_ticket(9); + let metadata = TransferMetadata::new(7, "Wrapped", None, blob_ticket.hash(), 1, 10); + let encoded = VnidropTicket::new(blob_ticket.clone(), metadata) + .encode() + .unwrap(); + let wrapped = encoded + .as_bytes() + .chunks(8) + .map(|chunk| std::str::from_utf8(chunk).unwrap()) + .collect::>() + .join("\n "); + + let parsed = parse_transfer_ticket(&wrapped).unwrap(); + assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash()); +} + +#[test] +fn invalid_ticket_is_rejected() { + assert!(parse_transfer_ticket("not-a-ticket").is_err()); +} + +#[test] +fn rejects_unsupported_versions_and_mismatched_hashes() { + let blob_ticket = blob_ticket(5); + let payload = json!({ + "version": 2, + "blob_ticket": blob_ticket.to_string(), + "metadata": { + "version": 1, + "transfer_id": 7, + "transfer_name": "bad version", + "sender_name": null, + "created_at": 1, + "content_hash": blob_ticket.hash().to_string(), + "file_count": 1, + "total_size": 10 + } + }); + let encoded = format!( + "vnd1:{}", + BASE64URL_NOPAD.encode(payload.to_string().as_bytes()) + ); + assert!(parse_transfer_ticket(&encoded) + .unwrap_err() + .to_string() + .contains("unsupported VniDrop ticket version")); + + let payload = json!({ + "version": 1, + "blob_ticket": blob_ticket.to_string(), + "metadata": { + "version": 1, + "transfer_id": 7, + "transfer_name": "bad hash", + "sender_name": null, + "created_at": 1, + "content_hash": Hash::new([6; 32]).to_string(), + "file_count": 1, + "total_size": 10 + } + }); + let encoded = format!( + "vnd1:{}", + BASE64URL_NOPAD.encode(payload.to_string().as_bytes()) + ); + assert!(parse_transfer_ticket(&encoded) + .unwrap_err() + .to_string() + .contains("metadata hash does not match")); +} + +#[test] +fn rejects_ticket_over_configured_size_limit() { + let limits = CoreLimits { + max_ticket_bytes: 8, + ..CoreLimits::default() + }; + assert!(parse_transfer_ticket_with_limits("not-a-ticket", &limits).is_err()); +} + +#[test] +fn parser_rejects_or_parses_generated_inputs_without_panicking() { + let alphabet = b"vnd1:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ /\\\n"; + let mut state = 0x9e37_79b9u32; + for len in 0..512usize { + let mut input = String::with_capacity(len); + for _ in 0..len { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + input.push(alphabet[state as usize % alphabet.len()] as char); + } + let _ = parse_transfer_ticket(&input); + } +} diff --git a/crates/vnidrop/src/tests/transfer_state.rs b/crates/vnidrop/src/tests/transfer_state.rs new file mode 100644 index 0000000..68360a9 --- /dev/null +++ b/crates/vnidrop/src/tests/transfer_state.rs @@ -0,0 +1,31 @@ +use crate::transfer_state::{TransferDirection, TransferStatus}; + +#[test] +fn parses_only_known_persisted_values() { + assert_eq!( + TransferDirection::try_from("send").unwrap(), + TransferDirection::Send + ); + assert_eq!( + TransferStatus::try_from("receiving").unwrap(), + TransferStatus::Receiving + ); + assert!(TransferDirection::try_from("sideways").is_err()); + assert!(TransferStatus::try_from("pending-ish").is_err()); +} + +#[test] +fn permits_only_defined_lifecycle_transitions() { + assert!(TransferStatus::Importing.can_transition_to(TransferStatus::Sharing)); + assert!(TransferStatus::Importing.can_transition_to(TransferStatus::Failed)); + assert!(TransferStatus::Importing.can_transition_to(TransferStatus::Cancelled)); + assert!(TransferStatus::Sharing.can_transition_to(TransferStatus::Stopped)); + assert!(TransferStatus::Sharing.can_transition_to(TransferStatus::Failed)); + assert!(TransferStatus::Receiving.can_transition_to(TransferStatus::Done)); + assert!(TransferStatus::Receiving.can_transition_to(TransferStatus::Failed)); + assert!(TransferStatus::Receiving.can_transition_to(TransferStatus::Cancelled)); + + assert!(!TransferStatus::Sharing.can_transition_to(TransferStatus::Done)); + assert!(!TransferStatus::Done.can_transition_to(TransferStatus::Receiving)); + assert!(!TransferStatus::Failed.can_transition_to(TransferStatus::Sharing)); +} diff --git a/crates/vnidrop/src/ticket.rs b/crates/vnidrop/src/ticket.rs index 665f744..76f9198 100644 --- a/crates/vnidrop/src/ticket.rs +++ b/crates/vnidrop/src/ticket.rs @@ -5,7 +5,7 @@ use data_encoding::BASE64URL_NOPAD; use iroh_blobs::ticket::BlobTicket; use serde::{Deserialize, Serialize}; -use crate::api::TransferMetadata; +use crate::api::{CoreLimits, TransferMetadata}; const VNIDROP_TICKET_PREFIX: &str = "vnd1:"; const VNIDROP_TICKET_VERSION: u8 = 1; @@ -52,7 +52,22 @@ pub(crate) struct ParsedTransferTicket { pub(crate) metadata: Option, } +#[cfg(test)] pub(crate) fn parse_transfer_ticket(value: &str) -> Result { + parse_transfer_ticket_with_limits(value, &CoreLimits::default()) +} + +pub(crate) fn parse_transfer_ticket_with_limits( + value: &str, + limits: &CoreLimits, +) -> Result { + if value.len() as u64 > limits.max_ticket_bytes { + anyhow::bail!( + "ticket is {} bytes, limit is {}", + value.len(), + limits.max_ticket_bytes + ); + } let normalized = normalize_ticket_input(value); if normalized.starts_with(VNIDROP_TICKET_PREFIX) { let ticket = VnidropTicket::decode(&normalized)?; @@ -71,6 +86,11 @@ pub(crate) fn parse_transfer_ticket(value: &str) -> Result if ticket.metadata.transfer_name.trim().is_empty() { anyhow::bail!("VniDrop ticket metadata is missing a transfer name"); } + limits.validate_metadata_text( + "transfer name", + Some(ticket.metadata.transfer_name.as_str()), + )?; + limits.validate_metadata_text("sender name", ticket.metadata.sender_name.as_deref())?; let blob_ticket = BlobTicket::from_str(&ticket.blob_ticket) .context("invalid BlobTicket inside VniDrop ticket")?; if ticket.metadata.content_hash != blob_ticket.hash().to_string() { diff --git a/crates/vnidrop/src/transfer_state.rs b/crates/vnidrop/src/transfer_state.rs new file mode 100644 index 0000000..7952671 --- /dev/null +++ b/crates/vnidrop/src/transfer_state.rs @@ -0,0 +1,117 @@ +use anyhow::{bail, Result}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TransferDirection { + Send, + Receive, +} + +impl TransferDirection { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Send => "send", + Self::Receive => "receive", + } + } +} + +impl TryFrom<&str> for TransferDirection { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + match value { + "send" => Ok(Self::Send), + "receive" => Ok(Self::Receive), + _ => bail!("unknown transfer direction: {value}"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TransferStatus { + Importing, + Sharing, + Receiving, + Done, + Failed, + Cancelled, + Stopped, +} + +impl TransferStatus { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Importing => "importing", + Self::Sharing => "sharing", + Self::Receiving => "receiving", + Self::Done => "done", + Self::Failed => "failed", + Self::Cancelled => "cancelled", + Self::Stopped => "stopped", + } + } + + pub(crate) const fn can_transition_to(self, next: Self) -> bool { + matches!( + (self, next), + ( + Self::Importing, + Self::Sharing | Self::Failed | Self::Cancelled + ) | (Self::Sharing, Self::Stopped | Self::Failed) + | (Self::Receiving, Self::Done | Self::Failed | Self::Cancelled) + ) + } +} + +impl TryFrom<&str> for TransferStatus { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + match value { + "importing" => Ok(Self::Importing), + "sharing" => Ok(Self::Sharing), + "receiving" => Ok(Self::Receiving), + "done" => Ok(Self::Done), + "failed" => Ok(Self::Failed), + "cancelled" => Ok(Self::Cancelled), + "stopped" => Ok(Self::Stopped), + _ => bail!("unknown transfer status: {value}"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReceiverRequestStatus { + Requested, + Accepted, + Refused, + Expired, + Completed, +} + +impl ReceiverRequestStatus { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Requested => "requested", + Self::Accepted => "accepted", + Self::Refused => "refused", + Self::Expired => "expired", + Self::Completed => "completed", + } + } +} + +impl TryFrom<&str> for ReceiverRequestStatus { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + match value { + "requested" => Ok(Self::Requested), + "accepted" => Ok(Self::Accepted), + "refused" => Ok(Self::Refused), + "expired" => Ok(Self::Expired), + "completed" => Ok(Self::Completed), + _ => bail!("unknown receiver request status: {value}"), + } + } +} diff --git a/crates/vnidrop/tests/README.md b/crates/vnidrop/tests/README.md new file mode 100644 index 0000000..a0eed30 --- /dev/null +++ b/crates/vnidrop/tests/README.md @@ -0,0 +1,44 @@ +# Core test organization + +VniDrop uses two complementary Rust test layers. + +## Internal tests + +Tests under `src/tests/` can exercise crate-private invariants without widening +the production API: + +- `access_policy.rs`: authorization and approval-session rules. +- `filesystem.rs`: source collection and path validation. +- `handshake.rs`: malformed protocol response handling. +- `limits.rs`: core-limit validation. +- `repository.rs`: schema, persistence, and transition invariants. +- `runtime.rs`: public error mapping and runtime orchestration. +- `secret.rs`: node identity persistence and file permissions. +- `ticket.rs`: ticket encoding, parsing, and metadata validation. +- `transfer_state.rs`: persisted enum parsing and legal lifecycle transitions. + +## Integration tests + +Files directly under `tests/` are black-box scenarios. They must use the public +`vnidrop` API and should be organized by behavior rather than implementation +module: + +- `approval.rs`: receiver authorization flows. +- `lifecycle.rs`: stop, restart, recovery, and revocation behavior. +- `output_sink.rs`: foreign output-sink contracts and failures. +- `transfer.rs`: end-to-end file and directory transfers. + +Reusable fixtures live in `tests/support/`. `CoreGuard` shuts down a test core +on drop, while `RecordingSink` and `MemoryOutputSink` keep assertions focused +on externally observable behavior. + +## Test requirements + +- Every bug fix must include a regression test. +- Avoid arbitrary sleeps. When polling an asynchronous boundary is unavoidable, + use a short interval and a bounded timeout with a useful failure message. +- Prefer deterministic IDs, inputs, and clocks. +- Do not expose production internals solely for integration tests. +- Failure tests must verify durable status and emitted events when applicable, + not only the returned error. +- Recovery tests must close the original core and reopen the same data directory. diff --git a/crates/vnidrop/tests/approval.rs b/crates/vnidrop/tests/approval.rs new file mode 100644 index 0000000..0800f68 --- /dev/null +++ b/crates/vnidrop/tests/approval.rs @@ -0,0 +1,186 @@ +mod support; + +use std::sync::Arc; + +use support::{ + receive_with_response, share_path, wait_for_receiver_request, CoreGuard, RecordingSink, + TestNode, +}; +use vnidrop::{CoreLimits, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode}; + +#[test] +fn public_share_receives_without_sender_approval() { + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("public.txt"); + std::fs::write(&source_path, b"public content").unwrap(); + let sender = TestNode::new(); + let receiver = TestNode::new(); + let share = sender + .core + .share_files( + vec![ShareSource { + kind: SourceKind::Path, + value: source_path.to_string_lossy().into_owned(), + display_name: Some("public.txt".to_string()), + is_directory: false, + }], + ShareMetadataInput { + transfer_id: 30, + transfer_name: Some("Public file".to_string()), + sender_name: Some("Sender".to_string()), + access_mode: TransferAccessMode::Public, + }, + ) + .unwrap(); + + receiver + .core + .receive( + share.ticket, + output_dir.path().to_string_lossy().into_owned(), + Some("Receiver".to_string()), + ) + .unwrap(); + + assert_eq!( + std::fs::read(output_dir.path().join("public.txt")).unwrap(), + b"public content" + ); + let deliveries = sender + .core + .list_receiver_requests(share.transfer_id) + .unwrap(); + assert_eq!(deliveries.len(), 1); + assert_eq!(deliveries[0].receiver_name.as_deref(), Some("Receiver")); + assert_eq!(deliveries[0].status, "completed"); + assert!(deliveries[0].completed_at.is_some()); +} + +#[test] +fn approval_required_denies_then_allows_receiver() { + let source_dir = tempfile::tempdir().unwrap(); + let denied_output = tempfile::tempdir().unwrap(); + let allowed_output = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("private.txt"); + std::fs::write(&source_path, b"approved content").unwrap(); + let sender = TestNode::new(); + let receiver = TestNode::new(); + let share = share_path(&sender.core, &source_path, 9, "private.txt", false); + + assert!(receive_with_response( + &sender.core, + share.transfer_id, + receiver.core.arc(), + share.ticket.clone(), + denied_output.path(), + false, + ) + .is_err()); + assert!(sender + .sink + .events() + .iter() + .any(|event| event.phase == "approval" && event.kind == "receiver-refused")); + + receive_with_response( + &sender.core, + share.transfer_id, + receiver.core.arc(), + share.ticket, + allowed_output.path(), + true, + ) + .unwrap(); + assert_eq!( + std::fs::read(allowed_output.path().join("private.txt")).unwrap(), + b"approved content" + ); + let completed = sender + .core + .list_receiver_requests(share.transfer_id) + .unwrap(); + assert!(completed + .iter() + .any(|request| request.status == "completed")); +} + +#[test] +fn receiver_can_cancel_while_waiting_for_approval() { + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("waiting.txt"); + std::fs::write(&source_path, b"waiting").unwrap(); + let sender = TestNode::new(); + let receiver = TestNode::new(); + let share = share_path(&sender.core, &source_path, 26, "waiting.txt", false); + let receiver_core = receiver.core.arc(); + let ticket = share.ticket; + let output = output_dir.path().to_string_lossy().to_string(); + let worker = std::thread::spawn(move || { + receiver_core.receive(ticket, output, Some("receiver".to_string())) + }); + + let request = wait_for_receiver_request(&sender.core, share.transfer_id); + receiver.core.cancel_transfer(share.transfer_id).unwrap(); + let _ = sender.core.respond_receiver_request( + request.id, + false, + Some("receiver-cancelled".to_string()), + ); + assert!(worker.join().unwrap().is_err()); + + let transfer = receiver + .core + .list_transfers() + .unwrap() + .into_iter() + .find(|transfer| transfer.transfer_id == share.transfer_id) + .unwrap(); + assert_eq!(transfer.status, "cancelled"); +} + +#[test] +fn pending_approval_limit_denies_excess_receiver() { + let sender_dir = tempfile::tempdir().unwrap(); + let source_dir = tempfile::tempdir().unwrap(); + let output_one = tempfile::tempdir().unwrap(); + let output_two = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("limited.txt"); + std::fs::write(&source_path, b"limited").unwrap(); + let limits = CoreLimits { + max_pending_approvals: 1, + ..CoreLimits::default() + }; + let sender = CoreGuard::start_with_limits( + sender_dir.path(), + Arc::new(RecordingSink::default()), + limits, + ); + let receiver_one = TestNode::new(); + let receiver_two = TestNode::new(); + let share = share_path(&sender, &source_path, 28, "limited.txt", false); + + let first_core = receiver_one.core.arc(); + let first_ticket = share.ticket.clone(); + let first_output = output_one.path().to_string_lossy().to_string(); + let first = std::thread::spawn(move || { + first_core.receive(first_ticket, first_output, Some("first".to_string())) + }); + let request = wait_for_receiver_request(&sender, share.transfer_id); + + let second = receiver_two.core.receive( + share.ticket, + output_two.path().to_string_lossy().to_string(), + Some("second".to_string()), + ); + assert!(second + .unwrap_err() + .to_string() + .contains("too-many-pending-approvals")); + + sender + .respond_receiver_request(request.id, false, Some("test complete".to_string())) + .unwrap(); + assert!(first.join().unwrap().is_err()); +} diff --git a/crates/vnidrop/tests/lifecycle.rs b/crates/vnidrop/tests/lifecycle.rs new file mode 100644 index 0000000..da4b086 --- /dev/null +++ b/crates/vnidrop/tests/lifecycle.rs @@ -0,0 +1,304 @@ +mod support; + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use support::{share_path, CoreGuard, RecordingSink, TestNode}; +use vnidrop::{CoreLimits, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode}; + +#[test] +fn share_creation_persists_selected_access_mode_atomically() { + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("public.txt"); + std::fs::write(&source_path, b"public share").unwrap(); + let sender = TestNode::new(); + + sender + .core + .share_files( + vec![ShareSource { + kind: SourceKind::Path, + value: source_path.to_string_lossy().into_owned(), + display_name: Some("public.txt".to_string()), + is_directory: false, + }], + ShareMetadataInput { + transfer_id: 9, + transfer_name: Some("Public file".to_string()), + sender_name: Some("Sender".to_string()), + access_mode: TransferAccessMode::Public, + }, + ) + .unwrap(); + + let transfer = sender.core.list_transfers().unwrap().remove(0); + assert_eq!(transfer.access_mode, TransferAccessMode::Public); +} + +#[test] +fn cancelling_share_updates_status_and_events() { + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("cancel.txt"); + std::fs::write(&source_path, b"cancel me").unwrap(); + let sender = TestNode::new(); + let share = share_path(&sender.core, &source_path, 10, "cancel.txt", false); + + sender.core.cancel_transfer(share.transfer_id).unwrap(); + + let transfers = sender.core.list_transfers().unwrap(); + assert_eq!(transfers[0].status, "stopped"); + assert!(sender + .sink + .events() + .iter() + .any(|event| event.kind == "share-stopped")); +} + +#[test] +fn deleting_share_revokes_it_and_removes_persisted_history() { + let source_dir = tempfile::tempdir().unwrap(); + let core_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("delete.txt"); + std::fs::write(&source_path, b"delete me").unwrap(); + + let sender = CoreGuard::start(core_dir.path(), Arc::new(RecordingSink::default())); + let share = share_path(&sender, &source_path, 101, "delete.txt", false); + sender.delete_transfer(share.transfer_id).unwrap(); + + assert!(sender.list_transfers().unwrap().is_empty()); + assert_eq!(sender.status().active_shares, 0); + drop(sender); + + let restarted = CoreGuard::start(core_dir.path(), Arc::new(RecordingSink::default())); + assert!(restarted.list_transfers().unwrap().is_empty()); + assert_eq!(restarted.status().active_shares, 0); +} + +#[test] +fn persisted_share_is_recovered_and_can_be_stopped_after_restart() { + let source_dir = tempfile::tempdir().unwrap(); + let core_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("persistent.txt"); + std::fs::write(&source_path, b"survives restart").unwrap(); + + let sender = CoreGuard::start(core_dir.path(), Arc::new(RecordingSink::default())); + let share = share_path(&sender, &source_path, 20, "persistent.txt", false); + drop(sender); + + let restarted = CoreGuard::start(core_dir.path(), Arc::new(RecordingSink::default())); + assert_eq!(restarted.status().active_shares, 1); + restarted.cancel_transfer(share.transfer_id).unwrap(); + + let transfer = restarted + .list_transfers() + .unwrap() + .into_iter() + .find(|transfer| transfer.transfer_id == share.transfer_id) + .unwrap(); + assert_eq!(transfer.status, "stopped"); + assert_eq!(restarted.status().active_shares, 0); +} + +#[test] +fn stopped_share_rejects_direct_legacy_blob_ticket() { + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("revoked.txt"); + std::fs::write(&source_path, b"must not be served").unwrap(); + let sender = TestNode::new(); + let receiver = TestNode::new(); + let share = share_path(&sender.core, &source_path, 21, "revoked.txt", false); + + sender.core.cancel_transfer(share.transfer_id).unwrap(); + let result = receiver.core.receive( + share.blob_ticket, + output_dir.path().to_string_lossy().to_string(), + Some("receiver".to_string()), + ); + + assert!(result.is_err(), "a stopped share must not serve blob bytes"); + assert!(!output_dir.path().join("revoked.txt").exists()); +} + +#[test] +fn failed_import_leaves_durable_failed_transfer() { + let source_dir = tempfile::tempdir().unwrap(); + let sender = TestNode::new(); + let transfer_id = 22; + + let result = sender.core.share_files( + vec![ShareSource { + kind: SourceKind::Path, + value: source_dir + .path() + .join("missing.txt") + .to_string_lossy() + .to_string(), + display_name: Some("missing.txt".to_string()), + is_directory: false, + }], + ShareMetadataInput { + transfer_id, + transfer_name: Some("missing".to_string()), + sender_name: None, + access_mode: TransferAccessMode::ApprovalRequired, + }, + ); + + assert!(result.is_err()); + let transfer = sender + .core + .list_transfers() + .unwrap() + .into_iter() + .find(|transfer| transfer.transfer_id == transfer_id) + .unwrap(); + assert_eq!(transfer.status, "failed"); + assert_eq!(sender.core.status().active_shares, 0); +} + +#[test] +fn duplicate_transfer_id_does_not_replace_active_share() { + let source_dir = tempfile::tempdir().unwrap(); + let first_path = source_dir.path().join("first.txt"); + let second_path = source_dir.path().join("second.txt"); + std::fs::write(&first_path, b"first").unwrap(); + std::fs::write(&second_path, b"second").unwrap(); + let sender = TestNode::new(); + let first = share_path(&sender.core, &first_path, 23, "first.txt", false); + + let duplicate = sender.core.share_files( + vec![ShareSource { + kind: SourceKind::Path, + value: second_path.to_string_lossy().to_string(), + display_name: Some("second.txt".to_string()), + is_directory: false, + }], + ShareMetadataInput { + transfer_id: first.transfer_id, + transfer_name: Some("second".to_string()), + sender_name: None, + access_mode: TransferAccessMode::ApprovalRequired, + }, + ); + + assert!(duplicate.is_err()); + assert_eq!(sender.core.status().active_shares, 1); + let transfer = sender + .core + .list_transfers() + .unwrap() + .into_iter() + .find(|transfer| transfer.transfer_id == first.transfer_id) + .unwrap(); + assert_eq!(transfer.status, "sharing"); + assert_eq!(transfer.transfer_name.as_deref(), Some("first.txt")); +} + +#[test] +fn access_mode_update_requires_active_persisted_share() { + let sender = TestNode::new(); + + assert!(sender + .core + .set_transfer_access_mode(999, TransferAccessMode::Public) + .is_err()); +} + +#[test] +fn source_limit_rejection_creates_no_transfer_state() { + let core_dir = tempfile::tempdir().unwrap(); + let source_dir = tempfile::tempdir().unwrap(); + let first = source_dir.path().join("one.txt"); + let second = source_dir.path().join("two.txt"); + std::fs::write(&first, b"one").unwrap(); + std::fs::write(&second, b"two").unwrap(); + let limits = CoreLimits { + max_sources: 1, + ..CoreLimits::default() + }; + let sender = + CoreGuard::start_with_limits(core_dir.path(), Arc::new(RecordingSink::default()), limits); + + let result = sender.share_files( + vec![ + ShareSource { + kind: SourceKind::Path, + value: first.to_string_lossy().to_string(), + display_name: Some("one.txt".to_string()), + is_directory: false, + }, + ShareSource { + kind: SourceKind::Path, + value: second.to_string_lossy().to_string(), + display_name: Some("two.txt".to_string()), + is_directory: false, + }, + ], + ShareMetadataInput { + transfer_id: 24, + transfer_name: Some("too many".to_string()), + sender_name: None, + access_mode: TransferAccessMode::ApprovalRequired, + }, + ); + + assert!(result.is_err()); + assert!(sender.list_transfers().unwrap().is_empty()); +} + +#[test] +fn cancellation_during_import_is_durable() { + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("large.bin"); + std::fs::File::create(&source_path) + .unwrap() + .set_len(256 * 1024 * 1024) + .unwrap(); + let sender = TestNode::new(); + let core = sender.core.arc(); + let worker = std::thread::spawn(move || { + core.share_files( + vec![ShareSource { + kind: SourceKind::Path, + value: source_path.to_string_lossy().to_string(), + display_name: Some("large.bin".to_string()), + is_directory: false, + }], + ShareMetadataInput { + transfer_id: 25, + transfer_name: Some("large".to_string()), + sender_name: None, + access_mode: TransferAccessMode::ApprovalRequired, + }, + ) + }); + + let started = Instant::now(); + loop { + if sender + .core + .list_transfers() + .unwrap() + .iter() + .any(|transfer| transfer.transfer_id == 25 && transfer.status == "importing") + { + break; + } + assert!(started.elapsed() < Duration::from_secs(10)); + std::thread::sleep(Duration::from_millis(10)); + } + sender.core.cancel_transfer(25).unwrap(); + assert!(worker.join().unwrap().is_err()); + + let transfer = sender + .core + .list_transfers() + .unwrap() + .into_iter() + .find(|transfer| transfer.transfer_id == 25) + .unwrap(); + assert_eq!(transfer.status, "cancelled"); + assert_eq!(sender.core.status().active_transfers, 0); + assert_eq!(sender.core.status().active_shares, 0); +} diff --git a/crates/vnidrop/tests/local_transfer.rs b/crates/vnidrop/tests/local_transfer.rs deleted file mode 100644 index cc249f3..0000000 --- a/crates/vnidrop/tests/local_transfer.rs +++ /dev/null @@ -1,314 +0,0 @@ -use std::{ - sync::{Arc, Mutex}, - time::{Duration, Instant}, -}; - -use vnidrop::{ - CoreEvent, CoreEventSink, ReceiverRequest, ShareMetadataInput, ShareSource, SourceKind, - VnidropCore, -}; - -#[derive(Default)] -struct RecordingSink { - events: Mutex>, -} - -impl CoreEventSink for RecordingSink { - fn on_event(&self, event: CoreEvent) { - self.events.lock().unwrap().push(event); - } -} - -impl RecordingSink { - fn events(&self) -> Vec { - self.events.lock().unwrap().clone() - } -} - -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, - ticket: String, - output_dir: String, - receiver_name: Option, - 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(); - let receiver_dir = tempfile::tempdir().unwrap(); - let output_dir = tempfile::tempdir().unwrap(); - let source_path = sender_dir.path().join("hello.txt"); - std::fs::write(&source_path, b"hello from vnidrop").unwrap(); - - let sender_sink = Arc::new(RecordingSink::default()); - let receiver_sink = Arc::new(RecordingSink::default()); - let sender = VnidropCore::initialize( - sender_dir.path().join("core").to_string_lossy().to_string(), - sender_sink.clone(), - ) - .unwrap(); - let receiver = VnidropCore::initialize( - receiver_dir - .path() - .join("core") - .to_string_lossy() - .to_string(), - receiver_sink.clone(), - ) - .unwrap(); - - let share = sender - .share_files( - vec![ShareSource { - kind: SourceKind::Path, - value: source_path.to_string_lossy().to_string(), - display_name: Some("hello.txt".to_string()), - is_directory: false, - }], - ShareMetadataInput { - transfer_id: 7, - transfer_name: Some("hello".to_string()), - sender_name: Some("sender".to_string()), - }, - ) - .unwrap(); - - 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(); - - assert_eq!( - std::fs::read(output_dir.path().join("hello.txt")).unwrap(), - b"hello from vnidrop" - ); - - sender.shutdown(); - receiver.shutdown(); -} - -#[test] -fn two_local_cores_transfer_directory() { - let sender_dir = tempfile::tempdir().unwrap(); - let receiver_dir = tempfile::tempdir().unwrap(); - let output_dir = tempfile::tempdir().unwrap(); - let source_root = sender_dir.path().join("photos"); - std::fs::create_dir_all(source_root.join("nested")).unwrap(); - std::fs::write(source_root.join("cover.txt"), b"cover").unwrap(); - std::fs::write(source_root.join("nested").join("inside.txt"), b"inside").unwrap(); - - let sender = VnidropCore::initialize( - sender_dir.path().join("core").to_string_lossy().to_string(), - Arc::new(RecordingSink::default()), - ) - .unwrap(); - let receiver = VnidropCore::initialize( - receiver_dir - .path() - .join("core") - .to_string_lossy() - .to_string(), - Arc::new(RecordingSink::default()), - ) - .unwrap(); - - let share = sender - .share_files( - vec![ShareSource { - kind: SourceKind::Path, - value: source_root.to_string_lossy().to_string(), - display_name: Some("photos".to_string()), - is_directory: true, - }], - ShareMetadataInput { - transfer_id: 8, - transfer_name: Some("photos".to_string()), - sender_name: Some("sender".to_string()), - }, - ) - .unwrap(); - - 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(); - - assert_eq!( - std::fs::read(output_dir.path().join("photos").join("cover.txt")).unwrap(), - b"cover" - ); - assert_eq!( - std::fs::read( - output_dir - .path() - .join("photos") - .join("nested") - .join("inside.txt") - ) - .unwrap(), - b"inside" - ); - - sender.shutdown(); - receiver.shutdown(); -} - -#[test] -fn approval_required_denies_then_allows_receiver() { - let sender_dir = tempfile::tempdir().unwrap(); - let receiver_dir = tempfile::tempdir().unwrap(); - let denied_output = tempfile::tempdir().unwrap(); - let allowed_output = tempfile::tempdir().unwrap(); - let source_path = sender_dir.path().join("private.txt"); - std::fs::write(&source_path, b"approved content").unwrap(); - - let sender_sink = Arc::new(RecordingSink::default()); - let sender = VnidropCore::initialize( - sender_dir.path().join("core").to_string_lossy().to_string(), - sender_sink.clone(), - ) - .unwrap(); - let receiver = VnidropCore::initialize( - receiver_dir - .path() - .join("core") - .to_string_lossy() - .to_string(), - Arc::new(RecordingSink::default()), - ) - .unwrap(); - - let share = sender - .share_files( - vec![ShareSource { - kind: SourceKind::Path, - value: source_path.to_string_lossy().to_string(), - display_name: Some("private.txt".to_string()), - is_directory: false, - }], - ShareMetadataInput { - transfer_id: 9, - transfer_name: Some("private".to_string()), - sender_name: None, - }, - ) - .unwrap(); - 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 == "approval" && event.kind == "receiver-refused")); - - 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!( - std::fs::read(allowed_output.path().join("private.txt")).unwrap(), - b"approved content" - ); - - sender.shutdown(); - receiver.shutdown(); -} - -#[test] -fn cancelling_share_updates_status_and_events() { - let sender_dir = tempfile::tempdir().unwrap(); - let source_path = sender_dir.path().join("cancel.txt"); - std::fs::write(&source_path, b"cancel me").unwrap(); - let sink = Arc::new(RecordingSink::default()); - let sender = VnidropCore::initialize( - sender_dir.path().join("core").to_string_lossy().to_string(), - sink.clone(), - ) - .unwrap(); - - let share = sender - .share_files( - vec![ShareSource { - kind: SourceKind::Path, - value: source_path.to_string_lossy().to_string(), - display_name: Some("cancel.txt".to_string()), - is_directory: false, - }], - ShareMetadataInput { - transfer_id: 10, - transfer_name: Some("cancel".to_string()), - sender_name: None, - }, - ) - .unwrap(); - sender.cancel_transfer(share.transfer_id).unwrap(); - - let transfers = sender.list_transfers().unwrap(); - assert_eq!(transfers[0].status, "stopped"); - assert!(sink - .events() - .iter() - .any(|event| event.kind == "share-stopped")); - sender.shutdown(); -} diff --git a/crates/vnidrop/tests/output_sink.rs b/crates/vnidrop/tests/output_sink.rs new file mode 100644 index 0000000..e8512d8 --- /dev/null +++ b/crates/vnidrop/tests/output_sink.rs @@ -0,0 +1,104 @@ +mod support; + +use std::sync::Arc; + +use std::time::{Duration, Instant}; + +use support::{ + receive_with_sink_response, share_path, wait_for_receiver_request, MemoryOutputSink, TestNode, +}; + +#[test] +fn exports_nested_files_to_output_sink() { + let source_dir = tempfile::tempdir().unwrap(); + let source_root = source_dir.path().join("photos"); + std::fs::create_dir_all(source_root.join("nested")).unwrap(); + std::fs::write(source_root.join("cover.txt"), b"cover").unwrap(); + std::fs::write(source_root.join("nested/inside.txt"), b"inside").unwrap(); + let sender = TestNode::new(); + let receiver = TestNode::new(); + let share = share_path(&sender.core, &source_root, 18, "photos", true); + let output_sink = Arc::new(MemoryOutputSink::default()); + + receive_with_sink_response( + &sender.core, + share.transfer_id, + receiver.core.arc(), + share.ticket, + output_sink.clone(), + true, + ) + .unwrap(); + + assert_eq!(output_sink.file("photos/cover.txt"), b"cover"); + assert_eq!(output_sink.file("photos/nested/inside.txt"), b"inside"); + assert_eq!( + output_sink.terminal_state("photos/cover.txt"), + Some("finished") + ); + assert_eq!( + output_sink.terminal_state("photos/nested/inside.txt"), + Some("finished") + ); +} + +#[test] +fn reports_output_sink_write_failure() { + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("hello.txt"); + std::fs::write(&source_path, b"hello").unwrap(); + let sender = TestNode::new(); + let receiver = TestNode::new(); + let share = share_path(&sender.core, &source_path, 19, "hello.txt", false); + let output_sink = Arc::new(MemoryOutputSink::failing_writes()); + + let error = receive_with_sink_response( + &sender.core, + share.transfer_id, + receiver.core.arc(), + share.ticket, + output_sink.clone(), + true, + ) + .unwrap_err(); + + assert!(error.contains("sink write failed")); + assert_eq!(output_sink.terminal_state("hello.txt"), Some("aborted")); +} + +#[test] +fn cancellation_during_export_aborts_open_sink_file() { + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("slow.bin"); + std::fs::File::create(&source_path) + .unwrap() + .set_len(32 * 1024 * 1024) + .unwrap(); + let sender = TestNode::new(); + let receiver = TestNode::new(); + let share = share_path(&sender.core, &source_path, 29, "slow.bin", false); + let output_sink = Arc::new(MemoryOutputSink::slow_writes(Duration::from_millis(25))); + let receiver_core = receiver.core.arc(); + let sink_for_worker = output_sink.clone(); + let worker = std::thread::spawn(move || { + receiver_core.receive_with_output_sink( + share.ticket, + sink_for_worker, + Some("receiver".to_string()), + ) + }); + + let request = wait_for_receiver_request(&sender.core, share.transfer_id); + sender + .core + .respond_receiver_request(request.id, true, None) + .unwrap(); + let started = Instant::now(); + while !output_sink.has_started("slow.bin") { + assert!(started.elapsed() < Duration::from_secs(15)); + std::thread::sleep(Duration::from_millis(10)); + } + receiver.core.cancel_transfer(share.transfer_id).unwrap(); + assert!(worker.join().unwrap().is_err()); + assert_eq!(output_sink.terminal_state("slow.bin"), Some("aborted")); +} diff --git a/crates/vnidrop/tests/support/mod.rs b/crates/vnidrop/tests/support/mod.rs new file mode 100644 index 0000000..6989e9b --- /dev/null +++ b/crates/vnidrop/tests/support/mod.rs @@ -0,0 +1,263 @@ +// Cargo compiles every file in `tests/` as a separate crate, and each scenario +// intentionally uses only a subset of this shared harness. +#![allow(dead_code)] + +use std::{ + collections::HashMap, + ops::Deref, + path::Path, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +use vnidrop::{ + CoreEvent, CoreEventSink, CoreLimits, ReceiveOutputSink, ReceiverRequest, ShareMetadataInput, + ShareResult, ShareSource, SourceKind, TransferAccessMode, VnidropCore, VnidropError, +}; + +#[derive(Default)] +pub struct RecordingSink { + events: Mutex>, +} + +impl CoreEventSink for RecordingSink { + fn on_event(&self, event: CoreEvent) { + self.events.lock().unwrap().push(event); + } +} + +impl RecordingSink { + pub fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } +} + +pub struct CoreGuard(Arc); + +impl CoreGuard { + pub fn start(path: &Path, sink: Arc) -> Self { + Self( + VnidropCore::initialize(path.to_string_lossy().to_string(), sink) + .expect("test core should initialize"), + ) + } + + pub fn start_with_limits( + path: &Path, + sink: Arc, + limits: CoreLimits, + ) -> Self { + Self( + VnidropCore::initialize_with_limits(path.to_string_lossy().to_string(), sink, limits) + .expect("test core should initialize with limits"), + ) + } + + pub fn arc(&self) -> Arc { + self.0.clone() + } +} + +impl Deref for CoreGuard { + type Target = VnidropCore; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Drop for CoreGuard { + fn drop(&mut self) { + self.0.shutdown(); + } +} + +pub struct TestNode { + _data_dir: tempfile::TempDir, + pub core: CoreGuard, + pub sink: Arc, +} + +impl TestNode { + pub fn new() -> Self { + let data_dir = tempfile::tempdir().unwrap(); + let sink = Arc::new(RecordingSink::default()); + let core = CoreGuard::start(data_dir.path(), sink.clone()); + Self { + _data_dir: data_dir, + core, + sink, + } + } +} + +#[derive(Default)] +pub struct MemoryOutputSink { + files: Mutex>>, + terminal: Mutex>, + fail_writes: bool, + write_delay: Duration, +} + +impl MemoryOutputSink { + pub fn failing_writes() -> Self { + Self { + files: Mutex::new(HashMap::new()), + terminal: Mutex::new(HashMap::new()), + fail_writes: true, + write_delay: Duration::ZERO, + } + } + + pub fn slow_writes(delay: Duration) -> Self { + Self { + files: Mutex::new(HashMap::new()), + terminal: Mutex::new(HashMap::new()), + fail_writes: false, + write_delay: delay, + } + } + + pub fn file(&self, relative_path: &str) -> Vec { + self.files.lock().unwrap()[relative_path].clone() + } + + pub fn terminal_state(&self, relative_path: &str) -> Option<&'static str> { + self.terminal.lock().unwrap().get(relative_path).copied() + } + + pub fn has_started(&self, relative_path: &str) -> bool { + self.files.lock().unwrap().contains_key(relative_path) + } +} + +impl ReceiveOutputSink for MemoryOutputSink { + fn start_file(&self, relative_path: String) -> Result<(), VnidropError> { + self.files.lock().unwrap().insert(relative_path, Vec::new()); + Ok(()) + } + + fn write_chunk(&self, relative_path: String, bytes: Vec) -> Result<(), VnidropError> { + if !self.write_delay.is_zero() { + std::thread::sleep(self.write_delay); + } + if self.fail_writes { + return Err(VnidropError::Filesystem { + reason: "sink write failed".to_string(), + }); + } + self.files + .lock() + .unwrap() + .get_mut(&relative_path) + .expect("file was not started") + .extend(bytes); + Ok(()) + } + + fn finish_file(&self, relative_path: String) -> Result<(), VnidropError> { + self.terminal + .lock() + .unwrap() + .insert(relative_path, "finished"); + Ok(()) + } + + fn abort_file(&self, relative_path: String, _reason: String) -> Result<(), VnidropError> { + self.files.lock().unwrap().remove(&relative_path); + self.terminal + .lock() + .unwrap() + .insert(relative_path, "aborted"); + Ok(()) + } +} + +pub fn share_path( + sender: &VnidropCore, + source: &Path, + transfer_id: u64, + display_name: &str, + is_directory: bool, +) -> ShareResult { + sender + .share_files( + vec![ShareSource { + kind: SourceKind::Path, + value: source.to_string_lossy().to_string(), + display_name: Some(display_name.to_string()), + is_directory, + }], + ShareMetadataInput { + transfer_id, + transfer_name: Some(display_name.to_string()), + sender_name: Some("sender".to_string()), + access_mode: TransferAccessMode::ApprovalRequired, + }, + ) + .expect("test share should be created") +} + +pub 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(25)); + } +} + +pub fn receive_with_response( + sender: &VnidropCore, + transfer_id: u64, + receiver: Arc, + ticket: String, + output_dir: &Path, + accepted: bool, +) -> Result<(), String> { + let output_dir = output_dir.to_string_lossy().to_string(); + let handle = std::thread::spawn(move || { + receiver + .receive(ticket, output_dir, Some("receiver".to_string())) + .map_err(|error| error.to_string()) + }); + respond_to_pending_request(sender, transfer_id, accepted); + handle.join().unwrap() +} + +pub fn receive_with_sink_response( + sender: &VnidropCore, + transfer_id: u64, + receiver: Arc, + ticket: String, + output_sink: Arc, + accepted: bool, +) -> Result<(), String> { + let handle = std::thread::spawn(move || { + receiver + .receive_with_output_sink(ticket, output_sink, Some("receiver".to_string())) + .map_err(|error| error.to_string()) + }); + respond_to_pending_request(sender, transfer_id, accepted); + handle.join().unwrap() +} + +fn respond_to_pending_request(sender: &VnidropCore, transfer_id: u64, accepted: bool) { + let request = wait_for_receiver_request(sender, transfer_id); + sender + .respond_receiver_request( + request.id, + accepted, + (!accepted).then(|| "sender-refused".to_string()), + ) + .unwrap(); +} diff --git a/crates/vnidrop/tests/transfer.rs b/crates/vnidrop/tests/transfer.rs new file mode 100644 index 0000000..d8af022 --- /dev/null +++ b/crates/vnidrop/tests/transfer.rs @@ -0,0 +1,104 @@ +mod support; + +use support::{receive_with_response, share_path, TestNode}; + +#[test] +fn transfers_file_between_two_cores() { + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("hello.txt"); + std::fs::write(&source_path, b"hello from vnidrop").unwrap(); + let sender = TestNode::new(); + let receiver = TestNode::new(); + + let share = share_path(&sender.core, &source_path, 7, "hello.txt", false); + receive_with_response( + &sender.core, + share.transfer_id, + receiver.core.arc(), + share.ticket, + output_dir.path(), + true, + ) + .unwrap(); + + assert_eq!( + std::fs::read(output_dir.path().join("hello.txt")).unwrap(), + b"hello from vnidrop" + ); + let received = receiver + .core + .list_transfers() + .unwrap() + .into_iter() + .find(|transfer| transfer.transfer_id == 7) + .unwrap(); + assert!(!received.local_id.is_empty()); + assert_eq!( + received.peer_id.as_deref(), + Some(sender.core.status().endpoint_id.as_str()) + ); +} + +#[test] +fn transfers_directory_between_two_cores() { + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_root = source_dir.path().join("photos"); + std::fs::create_dir_all(source_root.join("nested")).unwrap(); + std::fs::write(source_root.join("cover.txt"), b"cover").unwrap(); + std::fs::write(source_root.join("nested/inside.txt"), b"inside").unwrap(); + let sender = TestNode::new(); + let receiver = TestNode::new(); + + let share = share_path(&sender.core, &source_root, 8, "photos", true); + receive_with_response( + &sender.core, + share.transfer_id, + receiver.core.arc(), + share.ticket, + output_dir.path(), + true, + ) + .unwrap(); + + assert_eq!( + std::fs::read(output_dir.path().join("photos/cover.txt")).unwrap(), + b"cover" + ); + assert_eq!( + std::fs::read(output_dir.path().join("photos/nested/inside.txt")).unwrap(), + b"inside" + ); +} + +#[test] +fn receive_refuses_to_overwrite_existing_destination() { + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("existing.txt"); + let output_path = output_dir.path().join("existing.txt"); + std::fs::write(&source_path, b"new content").unwrap(); + std::fs::write(&output_path, b"keep content").unwrap(); + let sender = TestNode::new(); + let receiver = TestNode::new(); + let share = share_path(&sender.core, &source_path, 27, "existing.txt", false); + + assert!(receive_with_response( + &sender.core, + share.transfer_id, + receiver.core.arc(), + share.ticket, + output_dir.path(), + true, + ) + .is_err()); + assert_eq!(std::fs::read(&output_path).unwrap(), b"keep content"); + assert!(std::fs::read_dir(output_dir.path()) + .unwrap() + .all(|entry| !entry + .unwrap() + .file_name() + .to_string_lossy() + .contains(".part"))); +} diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 01b21e3..803b6e8 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { implementation(libs.jna) implementation(libs.compose.uiToolingPreview) + testImplementation(libs.kotlin.testJunit) } compose.desktop { diff --git a/desktopApp/src/main/kotlin/com/vnidrop/app/MacOsAppKitAppearance.kt b/desktopApp/src/main/kotlin/com/vnidrop/app/MacOsAppKitAppearance.kt index 44eb707..9e3efb8 100644 --- a/desktopApp/src/main/kotlin/com/vnidrop/app/MacOsAppKitAppearance.kt +++ b/desktopApp/src/main/kotlin/com/vnidrop/app/MacOsAppKitAppearance.kt @@ -1,9 +1,12 @@ package com.vnidrop.app +import com.sun.jna.Callback import com.sun.jna.Library import com.sun.jna.Native import com.sun.jna.NativeLibrary import com.sun.jna.Pointer +import com.sun.jna.Structure +import java.io.File internal object MacOsAppKitAppearance { private val objc: ObjCRuntime? by lazy { @@ -42,10 +45,112 @@ internal object MacOsAppKitAppearance { System.getProperty("os.name").startsWith("Mac", ignoreCase = true) } +internal object MacOsShareSheet { + private val objc: ObjCRuntime? by lazy { + runCatching { + NativeLibrary.getInstance("AppKit") + Native.load("objc", ObjCRuntime::class.java) + }.getOrNull() + } + private var retainedPicker: Pointer? = null + private val systemLibrary: NativeLibrary? by lazy { + runCatching { NativeLibrary.getInstance("System") }.getOrNull() + } + private val dispatch: DispatchRuntime? by lazy { + runCatching { Native.load("System", DispatchRuntime::class.java) }.getOrNull() + } + + fun share(file: File): Result = runCatching { + require(file.isFile) { "The invitation file could not be created" } + var failure: Throwable? = null + val runtime = dispatch ?: error("The macOS main queue is unavailable") + // dispatch_get_main_queue() is a C macro on Darwin, so there is no + // function for dlsym/JNA to resolve. The macro returns this exported + // queue object directly. + val queue = systemLibrary?.getGlobalVariableAddress("_dispatch_main_q") + ?: error("The macOS main queue is unavailable") + runtime.dispatch_sync_f(queue, null, DispatchWork { failure = runCatching { show(file) }.exceptionOrNull() }) + failure?.let { throw it } + } + + private fun show(file: File) { + val runtime = objc ?: error("AppKit is unavailable") + val applicationClass = runtime.objc_getClass("NSApplication") ?: error("NSApplication is unavailable") + val application = runtime.objc_msgSend(applicationClass, runtime.sel_registerName("sharedApplication")) + ?: error("NSApplication could not be opened") + val window = runtime.objc_msgSend(application, runtime.sel_registerName("keyWindow")) + ?: runtime.objc_msgSend(application, runtime.sel_registerName("mainWindow")) + ?: error("No active macOS window") + val contentView = runtime.objc_msgSend(window, runtime.sel_registerName("contentView")) + ?: error("The active window has no content view") + val path = nsString(runtime, file.absolutePath) ?: error("The invitation path is invalid") + val urlClass = runtime.objc_getClass("NSURL") ?: error("NSURL is unavailable") + val url = runtime.objc_msgSend(urlClass, runtime.sel_registerName("fileURLWithPath:"), path) + ?: error("The invitation URL could not be created") + val arrayClass = runtime.objc_getClass("NSArray") ?: error("NSArray is unavailable") + val items = runtime.objc_msgSend(arrayClass, runtime.sel_registerName("arrayWithObject:"), url) + ?: error("The share item could not be created") + val pickerClass = runtime.objc_getClass("NSSharingServicePicker") ?: error("The macOS share sheet is unavailable") + val allocated = runtime.objc_msgSend(pickerClass, runtime.sel_registerName("alloc")) + ?: error("The macOS share sheet could not be allocated") + val picker = runtime.objc_msgSend(allocated, runtime.sel_registerName("initWithItems:"), items) + ?: error("The macOS share sheet could not be created") + retainedPicker?.let { runtime.objc_msgSend(it, runtime.sel_registerName("release")) } + retainedPicker = picker + runtime.objc_msgSend( + picker, + runtime.sel_registerName("showRelativeToRect:ofView:preferredEdge:"), + anchorRect(), + contentView, + 3L, + ) + } + + private fun nsString(runtime: ObjCRuntime, value: String): Pointer? { + val stringClass = runtime.objc_getClass("NSString") ?: return null + return runtime.objc_msgSend(stringClass, runtime.sel_registerName("stringWithUTF8String:"), value) + } + + internal fun validateNativeRectMapping(): Int = anchorRect().size() + internal fun hasNativeMainQueue(): Boolean = + runCatching { systemLibrary?.getGlobalVariableAddress("_dispatch_main_q") != null }.getOrDefault(false) + + private fun anchorRect() = NSRectByValue().apply { + x = 0.0 + y = 0.0 + width = 1.0 + height = 1.0 + write() + } +} + +@Structure.FieldOrder("x", "y", "width", "height") +internal class NSRectByValue : Structure(), Structure.ByValue { + @JvmField var x: Double = 0.0 + @JvmField var y: Double = 0.0 + @JvmField var width: Double = 0.0 + @JvmField var height: Double = 0.0 +} + private interface ObjCRuntime : Library { fun objc_getClass(name: String): Pointer? fun sel_registerName(name: String): Pointer fun objc_msgSend(receiver: Pointer?, selector: Pointer?): Pointer? fun objc_msgSend(receiver: Pointer?, selector: Pointer?, argument: Pointer?): Pointer? fun objc_msgSend(receiver: Pointer?, selector: Pointer?, argument: String): Pointer? + fun objc_msgSend( + receiver: Pointer?, + selector: Pointer?, + rect: NSRectByValue, + view: Pointer?, + edge: Long, + ): Pointer? +} + +private fun interface DispatchWork : Callback { + fun invoke(context: Pointer?) +} + +private interface DispatchRuntime : Library { + fun dispatch_sync_f(queue: Pointer?, context: Pointer?, work: DispatchWork) } diff --git a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt index 0ce6bb7..44ca458 100644 --- a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt +++ b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt @@ -3,16 +3,20 @@ package com.vnidrop.app import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import com.vnidrop.app.platform.DesktopAppearanceBridge +import com.vnidrop.app.feature.send.DesktopShareBridge fun main() { configureMacOsNativeAppearance() DesktopAppearanceBridge.applyNativeAppearance = MacOsAppKitAppearance::apply + if (System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) { + DesktopShareBridge.shareFile = MacOsShareSheet::share + } application { Window( onCloseRequest = ::exitApplication, title = "vnidrop", ) { - App() + App(rememberJvmAppDependencies()) } } } diff --git a/desktopApp/src/test/kotlin/com/vnidrop/app/MacOsShareSheetTest.kt b/desktopApp/src/test/kotlin/com/vnidrop/app/MacOsShareSheetTest.kt new file mode 100644 index 0000000..5ff6e6c --- /dev/null +++ b/desktopApp/src/test/kotlin/com/vnidrop/app/MacOsShareSheetTest.kt @@ -0,0 +1,17 @@ +package com.vnidrop.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MacOsShareSheetTest { + @Test + fun nativeAnchorRectHasTheCocoaLayout() { + assertEquals(32, MacOsShareSheet.validateNativeRectMapping()) + } + + @Test + fun nativeMainDispatchQueueCanBeResolved() { + assertTrue(MacOsShareSheet.hasNativeMainQueue()) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8ac0a6f..2039cee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,9 +5,10 @@ android-minSdk = "24" android-targetSdk = "36" androidx-activity = "1.13.0" androidx-appcompat = "1.7.1" -androidx-core = "1.19.0" +androidx-core = "1.18.0" androidx-espresso = "3.7.0" androidx-lifecycle = "2.11.0-beta01" +androidx-datastore = "1.2.1" androidx-testExt = "1.3.0" composeMultiplatform = "1.11.1" gobley = "0.3.7" @@ -15,6 +16,7 @@ junit = "4.13.2" kotlin = "2.4.0" kotlinx-coroutines = "1.11.0" material3 = "1.11.0-alpha07" +qrcode = "4.5.0" jna = "5.17.0" [libraries] @@ -29,14 +31,19 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidx-datastore" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" } compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } +compose-uiTest = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "composeMultiplatform" } compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } [plugins] diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 98aadd7..ddb9bf6 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -2,6 +2,7 @@ import gobley.gradle.cargo.dsl.appleMobile import gobley.gradle.rust.targets.RustAndroidTarget +import org.gradle.api.tasks.PathSensitivity import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { @@ -36,6 +37,7 @@ kotlin { sourceSets { androidMain.dependencies { implementation(libs.androidx.activity.compose) + implementation(libs.androidx.core.ktx) implementation(libs.compose.uiToolingPreview) } commonMain.dependencies { @@ -47,10 +49,18 @@ kotlin { implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) + implementation(libs.androidx.datastore) + implementation(libs.androidx.datastore.preferences) implementation(libs.kotlinx.coroutinesCore) + implementation(libs.qrcode.kotlin) } commonTest.dependencies { implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutinesTest) + } + jvmTest.dependencies { + implementation(compose.desktop.currentOs) + implementation(libs.compose.uiTest) } } } @@ -95,6 +105,18 @@ uniffi { } tasks.configureEach { + // Gobley does not currently treat every Rust source/API change as an input of + // all platform cargo tasks. Without these inputs an incremental Android build + // can package an older .so next to freshly generated UniFFI Kotlin bindings. + if (name.startsWith("cargoBuild")) { + inputs.files( + fileTree(layout.projectDirectory.dir("../crates/vnidrop")) { + include("Cargo.toml", "build.rs", "src/**/*.rs") + }, + layout.projectDirectory.file("../Cargo.toml"), + layout.projectDirectory.file("../Cargo.lock"), + ).withPathSensitivity(PathSensitivity.RELATIVE) + } if (name.contains("Linux") || name.contains("MinGW") || name.contains("MacOSX64")) { enabled = false } diff --git a/shared/src/androidMain/AndroidManifest.xml b/shared/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..b5b2645 --- /dev/null +++ b/shared/src/androidMain/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt index 34faa26..b96e861 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt @@ -5,60 +5,71 @@ import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.BatteryManager import android.os.Build +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vnidrop.app.core.rememberFileSystemService +import com.vnidrop.app.notifications.rememberAndroidLocalNotificationService import java.net.NetworkInterface -class AndroidPlatform : Platform { - override val name: String = "Android ${Build.VERSION.SDK_INT}" - override val defaultCoreDataDir: String = - System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop" - override val defaultReceiveDir: String = - System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop-receive" - override val deviceInfo: DeviceInfo = DeviceInfo( +@Composable +fun rememberAndroidAppDependencies(activity: ComponentActivity): AppDependencies { + val context = activity.applicationContext + val fileSystemService = rememberFileSystemService() + val notificationService = rememberAndroidLocalNotificationService(activity) + return remember(context, fileSystemService, notificationService) { + AppDependencies( + environment = PlatformEnvironment( + name = "Android ${Build.VERSION.SDK_INT}", + appVersion = context.appVersion(), + defaultCoreDataDir = context.filesDir.resolve("vnidrop").absolutePath, + defaultUsername = Build.DEVICE.takeIf(String::isNotBlank) ?: "Receiver", + ), + deviceInfoProvider = AndroidDeviceInfoProvider(context), + fileSystemService = fileSystemService, + localNotificationService = notificationService, + ) + } +} + +private class AndroidDeviceInfoProvider( + private val context: Context, +) : DeviceInfoProvider { + override suspend fun load(): DeviceInfo = DeviceInfo( deviceName = Build.DEVICE, deviceModel = listOf(Build.MANUFACTURER, Build.MODEL) - .filter { it.isNotBlank() } + .filter(String::isNotBlank) .joinToString(" ") .ifBlank { null }, operatingSystem = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})", - network = activeNetworkSummary(), - batteryLevel = batteryLevel(), + network = context.activeNetworkSummary(), + batteryLevel = context.batteryLevel(), ) } -actual fun getPlatform(): Platform = AndroidPlatform() +private fun Context.appVersion(): String = runCatching { + packageManager.getPackageInfo(packageName, 0).versionName +}.getOrNull()?.takeIf(String::isNotBlank) ?: "0.1.0" -fun attachAndroidPlatformContext(context: Context) { - AndroidPlatformContextHolder.context = context.applicationContext -} +private fun Context.activeNetworkSummary(): String? = runCatching { + val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return@runCatching networkInterfaceName() + val activeNetwork = manager.activeNetwork ?: return@runCatching networkInterfaceName() + val capabilities = manager.getNetworkCapabilities(activeNetwork) ?: return@runCatching networkInterfaceName() + when { + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "Mobile" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "Ethernet" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "VPN" + else -> networkInterfaceName() + } +}.getOrNull() -private object AndroidPlatformContextHolder { - var context: Context? = null -} - -private fun activeNetworkSummary(): String? = - runCatching { - val context = AndroidPlatformContextHolder.context ?: return@runCatching networkInterfaceName() - val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager - ?: return@runCatching networkInterfaceName() - val activeNetwork = manager.activeNetwork ?: return@runCatching networkInterfaceName() - val capabilities = manager.getNetworkCapabilities(activeNetwork) ?: return@runCatching networkInterfaceName() - - when { - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi" - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "Mobile" - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "Ethernet" - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "VPN" - else -> networkInterfaceName() - } - }.getOrNull() - -private fun batteryLevel(): String? = - runCatching { - val context = AndroidPlatformContextHolder.context ?: return@runCatching null - val manager = context.getSystemService(BatteryManager::class.java) - val level = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) - level.takeIf { it >= 0 }?.let { "$it%" } - }.getOrNull() +private fun Context.batteryLevel(): String? = runCatching { + val manager = getSystemService(BatteryManager::class.java) + val level = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) + level.takeIf { it >= 0 }?.let { "$it%" } +}.getOrNull() private fun networkInterfaceName(): String? = NetworkInterface.getNetworkInterfaces() diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt index 62b4a9e..f8116b9 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt @@ -1,7 +1,12 @@ package com.vnidrop.app.core import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Point import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract import android.provider.OpenableColumns import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -17,7 +22,7 @@ actual fun rememberShareFilePicker( val context = LocalContext.current val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> if (uri != null) { - onFilePicked(PickedShareFile(uri.toString(), context.displayName(uri))) + onFilePicked(context.pickedShareFile(uri)) } } return remember(launcher) { @@ -29,44 +34,65 @@ actual fun rememberShareFilePicker( } } -actual suspend fun sharePickedFile( - repository: CoreRepository, - file: PickedShareFile, - transferName: String, - senderName: String, -) { - val context = AndroidContextHolder.context - errorIfNull(context, "Android context has not been attached") - .contentResolver - .openFileDescriptor(Uri.parse(file.value), "r") - .use { descriptor -> - checkNotNull(descriptor) { "Could not open selected file descriptor" } - repository.shareFileDescriptor( - fd = descriptor.fd, - displayName = file.displayName, - transferName = transferName, - senderName = senderName, +@Composable +actual fun rememberReceiveFolderPicker( + onFolderPicked: (ReceiveFolder) -> Unit, + onError: (String) -> Unit, +): ReceiveFolderPicker { + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + if (uri != null) { + runCatching { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + } + onFolderPicked( + ReceiveFolder( + kind = ReceiveFolderKind.AndroidTreeUri, + value = uri.toString(), + displayName = uri.lastPathSegment ?: "Downloads", + ), ) } -} - -private object AndroidContextHolder { - var context: Context? = null -} - -fun attachAndroidFilePickerContext(context: Context) { - AndroidContextHolder.context = context.applicationContext -} - -private fun Context.displayName(uri: Uri): String { - contentResolver.query(uri, null, null, null, null)?.use { cursor -> - val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (nameIndex >= 0 && cursor.moveToFirst()) { - return cursor.getString(nameIndex) + } + return remember(launcher) { + object : ReceiveFolderPicker { + override fun pickFolder() { + launcher.launch(null) + } } } - return uri.lastPathSegment ?: "transfer" } -private fun errorIfNull(value: T?, message: String): T = - value ?: error(message) +private fun Context.pickedShareFile(uri: Uri): PickedShareFile { + var displayName: String? = null + var sizeBytes: ULong? = null + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (cursor.moveToFirst()) { + if (nameIndex >= 0 && !cursor.isNull(nameIndex)) displayName = cursor.getString(nameIndex) + if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) { + sizeBytes = cursor.getLong(sizeIndex).takeIf { it >= 0L }?.toULong() + } + } + } + return PickedShareFile( + value = uri.toString(), + displayName = displayName ?: uri.lastPathSegment ?: "transfer", + sizeBytes = sizeBytes, + thumbnailBytes = runCatching { + val bitmap = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + contentResolver.loadThumbnail(uri, android.util.Size(192, 192), null) + } else { + DocumentsContract.getDocumentThumbnail(contentResolver, uri, Point(192, 192), null) + }) ?: error("The document provider did not return a thumbnail") + java.io.ByteArrayOutputStream().use { output -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, output) + output.toByteArray() + } + }.getOrNull(), + ) +} diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt new file mode 100644 index 0000000..5f3d8ee --- /dev/null +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt @@ -0,0 +1,187 @@ +package com.vnidrop.app.core + +import android.content.Context +import android.net.Uri +import android.os.Environment +import android.provider.DocumentsContract +import androidx.core.net.toUri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import uniffi.vnidrop.ReceiveOutputSink +import java.io.OutputStream +import java.util.UUID + +@Composable +actual fun rememberFileSystemService(): FileSystemService { + val context = LocalContext.current.applicationContext + return remember(context) { AndroidFileSystemService(context) } +} + +private class AndroidFileSystemService( + private val context: Context, +) : FileSystemService { + override fun defaultReceiveFolder(): ReceiveFolder { + val path = context + .getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) + ?.absolutePath + ?: (System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop-receive") + return ReceiveFolder( + kind = ReceiveFolderKind.FileSystemPath, + value = path, + displayName = "Downloads", + ) + } + + override suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus = + when (folder.kind) { + ReceiveFolderKind.FileSystemPath -> validatePath(folder.value) + ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value) + ReceiveFolderKind.IosSecurityScopedUrl -> FolderAccessStatus.Unavailable + } + + override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? { + if (folder.kind != ReceiveFolderKind.AndroidTreeUri) return null + return AndroidTreeReceiveOutputSink(context, folder.value.toUri()) + } + + override suspend fun sharePickedFile( + repository: CoreGateway, + file: PickedShareFile, + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy, + ): Result = runCatching { + context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r").use { descriptor -> + checkNotNull(descriptor) { "Could not open selected file descriptor" } + repository.shareFileDescriptor( + fd = descriptor.fd, + displayName = file.displayName, + transferName = transferName, + senderName = senderName, + accessPolicy = accessPolicy, + ).getOrThrow() + } + } + + private fun validatePath(path: String): FolderAccessStatus = + runCatching { + val directory = java.io.File(path) + if (!directory.exists()) directory.mkdirs() + if (directory.isDirectory && directory.canWrite()) FolderAccessStatus.Writable else FolderAccessStatus.Unavailable + }.getOrDefault(FolderAccessStatus.Unavailable) + + private fun validateTreeUri(value: String): FolderAccessStatus { + val uri = Uri.parse(value) + val hasPermission = context.contentResolver.persistedUriPermissions.any { permission -> + permission.uri == uri && permission.isWritePermission + } + if (!hasPermission) return FolderAccessStatus.PermissionRequired + return runCatching { + val probe = AndroidTreeReceiveOutputSink(context, uri) + val probeName = ".vnidrop-write-test-${UUID.randomUUID()}" + probe.startFile(probeName) + probe.writeChunk(probeName, byteArrayOf()) + probe.abortFile(probeName, "write probe complete") + FolderAccessStatus.Writable + }.getOrDefault(FolderAccessStatus.Unavailable) + } +} + +private class AndroidTreeReceiveOutputSink( + private val context: Context, + private val treeUri: Uri, +) : ReceiveOutputSink { + private data class PendingDocument( + val stream: OutputStream, + val temporaryUri: Uri, + val parentUri: Uri, + val finalName: String, + ) + + private val pending = mutableMapOf() + + override fun startFile(relativePath: String) { + check(relativePath !in pending) { "Output stream is already open for $relativePath" } + val (parent, finalName) = resolveParent(relativePath) + check(findChild(parent, finalName) == null) { "Destination already exists: $relativePath" } + val temporaryName = ".$finalName.vnidrop-${UUID.randomUUID()}.part" + val temporaryUri = DocumentsContract.createDocument( + context.contentResolver, + parent, + "application/octet-stream", + temporaryName, + ) ?: error("Could not create temporary file for $relativePath") + val stream = context.contentResolver.openOutputStream(temporaryUri, "w") + ?: error("Could not open output stream for $relativePath") + pending[relativePath] = PendingDocument(stream, temporaryUri, parent, finalName) + } + + override fun writeChunk(relativePath: String, bytes: ByteArray) { + val document = pending[relativePath] ?: error("Output stream is not open for $relativePath") + document.stream.write(bytes) + } + + override fun finishFile(relativePath: String) { + val document = pending.remove(relativePath) ?: error("Output stream is not open for $relativePath") + try { + document.stream.close() + check(findChild(document.parentUri, document.finalName) == null) { "Destination already exists: $relativePath" } + checkNotNull( + DocumentsContract.renameDocument( + context.contentResolver, + document.temporaryUri, + document.finalName, + ), + ) { "Could not commit received file $relativePath" } + } catch (error: Throwable) { + runCatching { document.stream.close() } + DocumentsContract.deleteDocument(context.contentResolver, document.temporaryUri) + throw error + } + } + + override fun abortFile(relativePath: String, reason: String) { + val document = pending.remove(relativePath) ?: return + runCatching { document.stream.close() } + DocumentsContract.deleteDocument(context.contentResolver, document.temporaryUri) + } + + private fun resolveParent(relativePath: String): Pair { + val parts = relativePath.split('/').filter { it.isNotBlank() } + require(parts.isNotEmpty()) { "relative path must not be empty" } + var parent = DocumentsContract.buildDocumentUriUsingTree( + treeUri, + DocumentsContract.getTreeDocumentId(treeUri), + ) + parts.dropLast(1).forEach { name -> + parent = findChild(parent, name) + ?: DocumentsContract.createDocument(context.contentResolver, parent, DocumentsContract.Document.MIME_TYPE_DIR, name) + ?: error("Could not create directory $name") + } + return parent to parts.last() + } + + private fun findChild(parent: Uri, name: String): Uri? { + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( + treeUri, + DocumentsContract.getDocumentId(parent), + ) + context.contentResolver.query( + childrenUri, + arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME), + null, + null, + null, + )?.use { cursor -> + val idIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + val nameIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + while (cursor.moveToNext()) { + if (cursor.getString(nameIndex) == name) { + return DocumentsContract.buildDocumentUriUsingTree(treeUri, cursor.getString(idIndex)) + } + } + } + return null + } +} diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.android.kt new file mode 100644 index 0000000..1b185eb --- /dev/null +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.android.kt @@ -0,0 +1,22 @@ +package com.vnidrop.app.feature.send + +import java.io.File + +actual fun createPlatformPreviewStore(appDataDir: String): PlatformPreviewStore = JvmLikePreviewStore(File(appDataDir, "ui/previews")) + +private class JvmLikePreviewStore(private val directory: File) : PlatformPreviewStore { + override fun list(): List = directory.listFiles().orEmpty().mapNotNull { file -> + file.name.removeSuffix(".preview").toULongOrNull()?.let { PreviewFileInfo(it, file.length(), file.lastModified()) } + } + override fun read(transferId: ULong): ByteArray? = runCatching { file(transferId).takeIf(File::isFile)?.readBytes() }.getOrNull() + override fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean = runCatching { + directory.mkdirs() + val target = file(transferId) + if (target.isFile) return@runCatching true + val temporary = File(directory, ".${target.name}.tmp") + temporary.writeBytes(bytes) + temporary.renameTo(target).also { if (!it) temporary.delete() } + }.getOrDefault(false) + override fun delete(transferId: ULong) { file(transferId).delete() } + private fun file(transferId: ULong) = File(directory, "$transferId.preview") +} diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.android.kt new file mode 100644 index 0000000..08bb9e0 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.android.kt @@ -0,0 +1,105 @@ +package com.vnidrop.app.feature.send + +import android.content.ClipData +import android.content.Intent +import android.nfc.NdefMessage +import android.nfc.NdefRecord +import android.nfc.NfcAdapter +import android.nfc.tech.Ndef +import android.nfc.tech.NdefFormatable +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.FileProvider +import java.io.File + +@Composable +actual fun rememberTransferShareActions(): TransferShareActions { + val context = LocalContext.current + val activity = context as? ComponentActivity + val nfcEnabled = activity?.let { NfcAdapter.getDefaultAdapter(it)?.isEnabled == true } == true + var pendingExport by remember { mutableStateOf(null) } + val exporter = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument(InvitationMimeType), + ) { uri -> + val pending = pendingExport + pendingExport = null + if (pending != null && uri != null) { + pending.callback(runCatching { + context.contentResolver.openOutputStream(uri, "wt")?.use { it.write(pending.ticket.encodeToByteArray()) } + ?: error("The selected destination could not be opened") + }) + } + } + return remember(activity, exporter, nfcEnabled) { + object : TransferShareActions { + override val canUseNativeShare = activity != null + override val nfcAvailability = when { + activity == null -> NfcShareAvailability.Unavailable + nfcEnabled -> NfcShareAvailability.Available + else -> NfcShareAvailability.Unavailable + } + + override fun exportInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) { + pendingExport = PendingExport(ticket, onResult) + exporter.launch(invitationFileName(transferName)) + } + + override fun shareInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) { + onResult(runCatching { + val directory = File(context.cacheDir, "transfer-invitations").apply { mkdirs() } + val file = File(directory, invitationFileName(transferName)).apply { writeText(ticket) } + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + val intent = Intent(Intent.ACTION_SEND).apply { + type = InvitationMimeType + putExtra(Intent.EXTRA_STREAM, uri) + clipData = ClipData.newRawUri(file.name, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + }) + } + + override fun writeInvitationToNfc(ticket: String, onResult: (Result) -> Unit) { + val host = activity ?: return onResult(Result.failure(UnsupportedOperationException("NFC is unavailable"))) + val adapter = NfcAdapter.getDefaultAdapter(host) + if (adapter?.isEnabled != true) return onResult(Result.failure(UnsupportedOperationException("NFC is unavailable"))) + adapter.enableReaderMode(host, { tag -> + val result = runCatching { + val message = NdefMessage(arrayOf(NdefRecord.createMime(InvitationMimeType, ticket.encodeToByteArray()))) + val ndef = Ndef.get(tag) + if (ndef != null) { + ndef.connect() + try { + require(ndef.isWritable) { "This NFC tag is read-only" } + require(ndef.maxSize >= message.toByteArray().size) { "This NFC tag is too small" } + ndef.writeNdefMessage(message) + } finally { ndef.close() } + } else { + val formatable = NdefFormatable.get(tag) ?: error("This NFC tag cannot store an invitation") + formatable.connect() + try { formatable.format(message) } finally { formatable.close() } + } + } + host.runOnUiThread { + adapter.disableReaderMode(host) + onResult(result) + } + }, NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_NFC_F or NfcAdapter.FLAG_READER_NFC_V, null) + } + + override fun cancelNfcWrite() { + activity?.let { host -> NfcAdapter.getDefaultAdapter(host)?.disableReaderMode(host) } + } + } + } +} + +private data class PendingExport(val ticket: String, val callback: (Result) -> Unit) +private const val InvitationMimeType = "application/vnd.vnidrop.transfer" diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.android.kt new file mode 100644 index 0000000..8cc39ca --- /dev/null +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.android.kt @@ -0,0 +1,168 @@ +package com.vnidrop.app.notifications + +import android.annotation.SuppressLint +import android.Manifest +import android.app.NotificationChannel +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +@Composable +fun rememberAndroidLocalNotificationService(activity: ComponentActivity): LocalNotificationService { + val holder = viewModel { AndroidNotificationServiceHolder(activity.applicationContext) } + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + holder.service.completePermissionRequest(granted) + } + SideEffect { + holder.service.attachPermissionLauncher { + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + return holder.service +} + +private class AndroidNotificationServiceHolder(context: Context) : ViewModel() { + val service = AndroidLocalNotificationService(context) +} + +private class AndroidLocalNotificationService( + private val context: Context, +) : LocalNotificationService { + private val _permission = MutableStateFlow(currentPermission()) + override val permission: StateFlow = _permission.asStateFlow() + private var permissionContinuation: CancellableContinuation? = null + private var launchPermissionRequest: (() -> Unit)? = null + + init { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val manager = context.getSystemService(NotificationManager::class.java) + manager.createNotificationChannel( + NotificationChannel(ChannelId, "Connection requests", NotificationManager.IMPORTANCE_HIGH), + ) + } + } + + override suspend fun refreshPermission(): NotificationPermission = currentPermission().also { _permission.value = it } + + override suspend fun requestPermission(): NotificationPermission { + val current = refreshPermission() + if (current != NotificationPermission.NotDetermined) return current + return suspendCancellableCoroutine { continuation -> + permissionContinuation?.cancel() + permissionContinuation = continuation + continuation.invokeOnCancellation { permissionContinuation = null } + val launcher = launchPermissionRequest + if (launcher == null) { + permissionContinuation = null + continuation.resume(NotificationPermission.Denied) + } else { + markPermissionRequested() + launcher() + } + } + } + + override suspend fun openSettings(): Result = runCatching { + val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } else { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}")) + } + context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + + @SuppressLint("MissingPermission") + override suspend fun publish(notification: LocalNotification): Result = runCatching { + check(refreshPermission() == NotificationPermission.Granted) { "Notification permission is not granted" } + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) + val pendingIntent = launchIntent?.let { + PendingIntent.getActivity( + context, + notification.id.hashCode(), + it, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(context, ChannelId) + } else { + @Suppress("DEPRECATION") + Notification.Builder(context) + } + val built = builder + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setContentTitle(notification.title) + .setContentText(notification.body) + .setStyle(Notification.BigTextStyle().bigText(notification.body)) + .setAutoCancel(true) + .setPriority(Notification.PRIORITY_HIGH) + .setContentIntent(pendingIntent) + .build() + context.getSystemService(NotificationManager::class.java).notify(notification.id.hashCode(), built) + } + + override suspend fun cancel(id: String) { + context.getSystemService(NotificationManager::class.java).cancel(id.hashCode()) + } + + override suspend fun cancelAll() { + context.getSystemService(NotificationManager::class.java).cancelAll() + } + + fun completePermissionRequest(granted: Boolean) { + markPermissionRequested() + val result = if (granted) NotificationPermission.Granted else NotificationPermission.Denied + _permission.value = result + permissionContinuation?.takeIf { it.isActive }?.resume(result) + permissionContinuation = null + } + + fun attachPermissionLauncher(launcher: () -> Unit) { + launchPermissionRequest = launcher + } + + private fun currentPermission(): NotificationPermission { + val manager = context.getSystemService(NotificationManager::class.java) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return if (manager.areNotificationsEnabled()) NotificationPermission.Granted else NotificationPermission.Denied + } + val granted = context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + return when { + granted && manager.areNotificationsEnabled() -> NotificationPermission.Granted + wasPermissionRequested() -> NotificationPermission.Denied + else -> NotificationPermission.NotDetermined + } + } + + private fun markPermissionRequested() { + context.getSharedPreferences(PreferencesName, Context.MODE_PRIVATE).edit().putBoolean(PermissionRequestedKey, true).apply() + } + + private fun wasPermissionRequested(): Boolean = + context.getSharedPreferences(PreferencesName, Context.MODE_PRIVATE).getBoolean(PermissionRequestedKey, false) + + private companion object { + const val ChannelId = "vnidrop-connection-requests" + const val PreferencesName = "vnidrop-notifications" + const val PermissionRequestedKey = "permission-requested" + } +} diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 0e26a3e..e96c089 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -3,7 +3,65 @@ Receive Settings Send - Create a VniDrop ticket and approve receivers when required. + Files you have made available from this device. + Share your first file + Choose a file, decide who can receive it, then share it directly from this device. + New transfer + Your transfers + New transfer + Choose what to share + Select one file from this device. You can review its details before creating the transfer. + Review transfer + Who can receive it? + Ask before each download + You approve or refuse every new receiver. + Anyone with this transfer + No approval is required. Only use this for files you are comfortable sharing. + Size unavailable + Transfer created. + Transfer details + Activity + See important updates for this transfer + Receivers + Requests, approvals, and completed deliveries + Share + QR code, invitation file, and nearby options + Delete transfer? + “%1$s” will stop being shared and its transfer history will be removed from this device. + Deleting… + Transfer deleted. + There is no activity to show yet. + Nobody has requested this transfer yet. + Waiting for your approval + Approved — waiting for completion + Request refused + Request expired + Received successfully + Status unavailable + Nearby device + Scan with VniDrop to receive this transfer + Write to NFC tag + Save .vnd file + Share invitation + NFC tag writing is not available on this device. + Hold your device near a writable NFC tag. + Invitation saved. + Invitation written to the NFC tag. + Preparing the selected files + Transfer ready to share + A receiver requested access + Receiver access approved + Receiver access refused + A receiver completed the transfer + Sharing stopped + The transfer encountered a problem + Transfer updated + Choose file + Change file + Share file + Preparing transfer… + Copy transfer link + Create a new transfer Source Select a file to start a share. The app keeps bytes in Rust and platform file handles. Select file @@ -11,9 +69,9 @@ Transfer details Transfer name Sender name - Create share ticket - Creating ticket... - Share ticket + Create share + Creating share... + Share details Receiver requests Copy Use locally @@ -35,6 +93,19 @@ Configure the local node and app appearance. Node Appearance + Preferences + Username + Receive folder + Choose folder + Reset default + Back + Close + Cancel + Delete transfer + Writable + Permission required + Unavailable + Checking folder... Display mode System Dark mode @@ -50,6 +121,19 @@ Network Battery level Not available + Notifications + Allow notifications + Let VniDrop notify you about new connection requests while the app is running in the background. + Notifications are turned off for VniDrop. You can enable them in Settings. + Notifications are not available on this device. + Notifications enabled. + Could not open notification settings. + Open Settings + Dismiss + On + Off + Connection request + %1$d requests are waiting Ready Event log No events have been emitted yet. diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index 36b5770..d0eaa69 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -1,99 +1,132 @@ package com.vnidrop.app import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import com.vnidrop.app.core.rememberShareFilePicker -import com.vnidrop.app.logging.AppLogger +import com.vnidrop.app.feature.app.AppViewModel +import com.vnidrop.app.feature.app.AppGraphViewModel +import com.vnidrop.app.feature.approvals.ApprovalModalHost +import com.vnidrop.app.feature.receive.ReceiveRoute +import com.vnidrop.app.feature.receive.ReceiveViewModel +import com.vnidrop.app.feature.send.SendRoute +import com.vnidrop.app.feature.send.SendFloatingAction +import com.vnidrop.app.feature.send.SendViewModel +import com.vnidrop.app.feature.settings.SettingsRoute +import com.vnidrop.app.feature.settings.SettingsViewModel import com.vnidrop.app.platform.PlatformSystemAppearance +import com.vnidrop.app.ui.feedback.VniDropSnackbarHost import com.vnidrop.app.ui.navigation.AppDestination -import com.vnidrop.app.ui.screens.ReceiveScreen -import com.vnidrop.app.ui.screens.SendScreen -import com.vnidrop.app.ui.screens.SettingsScreen import com.vnidrop.app.ui.shell.AppShell +import com.vnidrop.app.ui.shell.ScreenScrollContainer +import com.vnidrop.app.core.TransferDirection +import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.state.windowClassFor import com.vnidrop.app.ui.theme.VniDropTheme import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme @Composable -@Preview -fun App() { - val platform = remember { getPlatform() } - val viewModel = viewModel { - VniDropAppViewModel( - appDataDir = platform.defaultCoreDataDir, - defaultReceiveDir = platform.defaultReceiveDir, - platformName = platform.name, +fun App(dependencies: AppDependencies) { + val graphHolder = viewModel { AppGraphViewModel(dependencies) } + val graph = graphHolder.graph + + val appViewModel = viewModel { + AppViewModel(dependencies.environment, graph.coreRepository, graph.preferencesRepository, graph.messages) + } + val sendViewModel = viewModel { + SendViewModel( + graph.coreRepository, + dependencies.fileSystemService, + graph.preferencesRepository, + graph.filePreviewRepository, + graph.messages, ) } - val state by viewModel.state.collectAsStateWithLifecycle() - val coreState by viewModel.coreState.collectAsStateWithLifecycle() - val clipboard = LocalClipboardManager.current - val picker = rememberShareFilePicker( - onFilePicked = { file -> - viewModel.onEvent(VniDropAppEvent.ShareFilePicked(file)) - }, - onError = { error -> - viewModel.onEvent(VniDropAppEvent.ShareFilePickFailed(error)) - }, - ) - - LaunchedEffect(viewModel) { - viewModel.effectFlow.collect { effect -> - when (effect) { - VniDropAppEffect.OpenShareFilePicker -> { - AppLogger.info("file-picker", "open share file picker") - picker.pickFile() - } - is VniDropAppEffect.CopyTicket -> { - AppLogger.info("send", "ticket copied") - clipboard.setText(AnnotatedString(effect.ticket)) + val receiveViewModel = viewModel { + ReceiveViewModel(graph.coreRepository, dependencies.fileSystemService, graph.preferencesRepository, graph.messages) + } + val settingsViewModel = viewModel { + SettingsViewModel( + dependencies.environment, + dependencies.deviceInfoProvider, + dependencies.fileSystemService, + graph.preferencesRepository, + dependencies.localNotificationService, + graph.messages, + ) + } + val appState by appViewModel.state.collectAsStateWithLifecycle() + val sendState by sendViewModel.state.collectAsStateWithLifecycle() + val sendCoreState by sendViewModel.coreState.collectAsStateWithLifecycle() + val approvalState by graph.approvalCoordinator.state.collectAsStateWithLifecycle() + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner, graph, settingsViewModel) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> { + graph.visibility.setForeground(true) + settingsViewModel.refreshNotificationPermission() } + Lifecycle.Event.ON_STOP -> graph.visibility.setForeground(false) + else -> Unit } } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } - val isDarkTheme = rememberResolvedDarkTheme(state.app.themeMode) - PlatformSystemAppearance(isDarkTheme) - LaunchedEffect(isDarkTheme) { - AppLogger.info("appearance", "system appearance synchronized", mapOf("dark" to isDarkTheme.toString())) - } - - VniDropTheme(isDarkTheme = isDarkTheme) { + val darkTheme = rememberResolvedDarkTheme(appState.themeMode) + PlatformSystemAppearance(darkTheme) + VniDropTheme(isDarkTheme = darkTheme) { BoxWithConstraints { val windowClass = windowClassFor(maxWidth.value) + val showSendAction = appState.destination == AppDestination.Send && + windowClass == WindowClass.Phone && + sendState.selectedTransferId?.let { selectedId -> + sendCoreState.transfers.any { it.transferId == selectedId } + } != true && + sendCoreState.transfers.any { it.direction == TransferDirection.Send } AppShell( - selectedDestination = state.app.destination, + modifier = Modifier.fillMaxSize(), + selectedDestination = appState.destination, windowClass = windowClass, - onDestinationSelected = { viewModel.onEvent(VniDropAppEvent.DestinationSelected(it)) }, + onDestinationSelected = appViewModel::selectDestination, + overlay = { + VniDropSnackbarHost(graph.messages, Modifier.align(Alignment.BottomCenter)) + }, + floatingAction = if (showSendAction) { + { + SendFloatingAction( + onClick = sendViewModel::openComposer, + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), + ) + } + } else { + null + }, ) { - when (state.app.destination) { - AppDestination.Send -> SendScreen( - coreState = coreState, - sendState = state.send, - onEvent = viewModel::onEvent, - ) - AppDestination.Receive -> ReceiveScreen( - coreState = coreState, - receiveState = state.receive, - onEvent = viewModel::onEvent, - ) - AppDestination.Settings -> SettingsScreen( - deviceInfo = platform.deviceInfo, - coreState = coreState, - themeMode = state.app.themeMode, - windowClass = windowClass, - onThemeModeChange = { viewModel.onEvent(VniDropAppEvent.ThemeModeChanged(it)) }, - ) + when (appState.destination) { + AppDestination.Send -> SendRoute(sendViewModel, windowClass) + AppDestination.Receive -> ScreenScrollContainer { ReceiveRoute(receiveViewModel) } + AppDestination.Settings -> ScreenScrollContainer { SettingsRoute(settingsViewModel, windowClass) } } } + ApprovalModalHost( + state = approvalState, + onAccept = graph.approvalCoordinator::accept, + onRefuse = graph.approvalCoordinator::refuse, + ) } } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt new file mode 100644 index 0000000..1d3d6e6 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt @@ -0,0 +1,56 @@ +package com.vnidrop.app + +import com.vnidrop.app.core.CoreGateway +import com.vnidrop.app.core.CoreRepository +import com.vnidrop.app.feature.approvals.ApprovalCoordinator +import com.vnidrop.app.feature.send.AppFilePreviewRepository +import com.vnidrop.app.feature.send.createPlatformPreviewStore +import com.vnidrop.app.logging.AppLogger +import com.vnidrop.app.platform.AppVisibility +import com.vnidrop.app.preferences.AppPreferencesDefaults +import com.vnidrop.app.preferences.AppPreferencesRepository +import com.vnidrop.app.preferences.createAppPreferencesDataStore +import com.vnidrop.app.ui.feedback.UiMessageController +import com.vnidrop.app.ui.theme.ThemeMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel + +class AppGraph( + val dependencies: AppDependencies, + private val applicationScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), + val coreRepository: CoreGateway = CoreRepository(), +) { + val visibility = AppVisibility() + val messages = UiMessageController() + val filePreviewRepository = AppFilePreviewRepository( + createPlatformPreviewStore(dependencies.environment.defaultCoreDataDir), + ) + val preferencesRepository = AppPreferencesRepository( + dataStore = createAppPreferencesDataStore(dependencies.environment.defaultCoreDataDir), + defaults = AppPreferencesDefaults( + username = dependencies.environment.defaultUsername, + receiveFolder = dependencies.fileSystemService.defaultReceiveFolder(), + themeMode = ThemeMode.System, + notificationsEnabled = false, + ), + ) + val approvalCoordinator = ApprovalCoordinator( + repository = coreRepository, + preferencesRepository = preferencesRepository, + notifications = dependencies.localNotificationService, + visibility = visibility, + messages = messages, + scope = applicationScope, + ) + + init { + AppLogger.initialize(dependencies.environment.defaultCoreDataDir) + } + + fun close() { + coreRepository.shutdown() + applicationScope.cancel() + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/Greeting.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/Greeting.kt deleted file mode 100644 index 2f46b02..0000000 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/Greeting.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.vnidrop.app - -class Greeting { - private val platform = getPlatform() - - fun greet(): String { - return sayHello(platform.name) - } -} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/GreetingUtil.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/GreetingUtil.kt deleted file mode 100644 index 2eeba45..0000000 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/GreetingUtil.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.vnidrop.app - -fun sayHello(to: String): String = - "Hello, $to!" diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt index cee02ae..23b146a 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt @@ -1,11 +1,14 @@ package com.vnidrop.app -interface Platform { - val name: String - val defaultCoreDataDir: String - val defaultReceiveDir: String - val deviceInfo: DeviceInfo -} +import com.vnidrop.app.core.FileSystemService +import com.vnidrop.app.notifications.LocalNotificationService + +data class PlatformEnvironment( + val name: String, + val appVersion: String, + val defaultCoreDataDir: String, + val defaultUsername: String = "Receiver", +) data class DeviceInfo( val deviceName: String?, @@ -15,4 +18,13 @@ data class DeviceInfo( val batteryLevel: String?, ) -expect fun getPlatform(): Platform +fun interface DeviceInfoProvider { + suspend fun load(): DeviceInfo +} + +data class AppDependencies( + val environment: PlatformEnvironment, + val deviceInfoProvider: DeviceInfoProvider, + val fileSystemService: FileSystemService, + val localNotificationService: LocalNotificationService, +) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/VniDropAppViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/VniDropAppViewModel.kt deleted file mode 100644 index 84746e9..0000000 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/VniDropAppViewModel.kt +++ /dev/null @@ -1,230 +0,0 @@ -package com.vnidrop.app - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.vnidrop.app.core.CoreRepository -import com.vnidrop.app.core.CoreUiState -import com.vnidrop.app.core.PickedShareFile -import com.vnidrop.app.core.sharePickedFile -import com.vnidrop.app.logging.AppLogger -import com.vnidrop.app.ui.navigation.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.theme.ThemeMode -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -data class VniDropAppState( - val app: AppUiState = AppUiState(), - val send: SendUiState = SendUiState(), - val receive: ReceiveUiState = ReceiveUiState(), -) - -sealed interface VniDropAppEvent { - data class DestinationSelected(val destination: AppDestination) : VniDropAppEvent - data class ThemeModeChanged(val mode: ThemeMode) : VniDropAppEvent - data object SelectFileClicked : VniDropAppEvent - data class ShareFilePicked(val file: PickedShareFile) : VniDropAppEvent - data class ShareFilePickFailed(val reason: String) : VniDropAppEvent - data object ClearSelectedSourceClicked : VniDropAppEvent - data class TransferNameChanged(val value: String) : VniDropAppEvent - data class SenderNameChanged(val value: String) : VniDropAppEvent - data object CreateShareClicked : VniDropAppEvent - data class CopyTicketClicked(val ticket: String) : VniDropAppEvent - data class UseTicketLocallyClicked(val ticket: String) : VniDropAppEvent - data class RefreshReceiverRequestsClicked(val transferId: ULong) : VniDropAppEvent - data class RespondReceiverRequestClicked(val requestId: String, val accepted: Boolean) : VniDropAppEvent - data class ReceiveTicketChanged(val value: String) : VniDropAppEvent - data class OutputDirectoryChanged(val value: String) : VniDropAppEvent - data class ReceiverNameChanged(val value: String) : VniDropAppEvent - data object InspectTicketClicked : VniDropAppEvent - data object ReceiveClicked : VniDropAppEvent -} - -sealed interface VniDropAppEffect { - data object OpenShareFilePicker : VniDropAppEffect - data class CopyTicket(val ticket: String) : VniDropAppEffect -} - -class VniDropAppViewModel( - appDataDir: String, - defaultReceiveDir: String, - platformName: String, - private val repository: CoreRepository = CoreRepository(), -) : ViewModel() { - private val _state = MutableStateFlow(VniDropAppState(receive = ReceiveUiState(outputDirectory = defaultReceiveDir))) - val state: StateFlow = _state - val coreState: StateFlow = repository.state - - private val effects = Channel(Channel.BUFFERED) - val effectFlow = effects.receiveAsFlow() - - private var selectedFile: PickedShareFile? = null - - init { - AppLogger.initialize(appDataDir) - AppLogger.info("lifecycle", "app started", mapOf("platform" to platformName)) - AppLogger.info("core", "automatic initialize requested", mapOf("appDataDir" to appDataDir)) - viewModelScope.launch { - repository.initialize(appDataDir) - } - } - - fun onEvent(event: VniDropAppEvent) { - when (event) { - is VniDropAppEvent.DestinationSelected -> updateAppState { copy(destination = event.destination) } - is VniDropAppEvent.ThemeModeChanged -> setThemeMode(event.mode) - VniDropAppEvent.SelectFileClicked -> sendEffect(VniDropAppEffect.OpenShareFilePicker) - is VniDropAppEvent.ShareFilePicked -> setSelectedFile(event.file) - is VniDropAppEvent.ShareFilePickFailed -> setFilePickerError(event.reason) - VniDropAppEvent.ClearSelectedSourceClicked -> clearSelectedSource() - is VniDropAppEvent.TransferNameChanged -> updateSendState { copy(transferName = event.value) } - is VniDropAppEvent.SenderNameChanged -> updateSendState { copy(senderName = event.value) } - VniDropAppEvent.CreateShareClicked -> createShare() - is VniDropAppEvent.CopyTicketClicked -> sendEffect(VniDropAppEffect.CopyTicket(event.ticket)) - is VniDropAppEvent.UseTicketLocallyClicked -> useTicketLocally(event.ticket) - is VniDropAppEvent.RefreshReceiverRequestsClicked -> refreshReceiverRequests(event.transferId) - is VniDropAppEvent.RespondReceiverRequestClicked -> respondReceiverRequest(event.requestId, event.accepted) - is VniDropAppEvent.ReceiveTicketChanged -> updateReceiveState { copy(ticket = event.value) } - is VniDropAppEvent.OutputDirectoryChanged -> updateReceiveState { copy(outputDirectory = event.value) } - is VniDropAppEvent.ReceiverNameChanged -> updateReceiveState { copy(receiverName = event.value) } - VniDropAppEvent.InspectTicketClicked -> inspectTicket() - VniDropAppEvent.ReceiveClicked -> receive() - } - } - - private fun setThemeMode(mode: ThemeMode) { - AppLogger.info("appearance", "theme mode changed", mapOf("mode" to mode.name)) - updateAppState { copy(themeMode = mode) } - } - - private fun setSelectedFile(file: PickedShareFile) { - AppLogger.info("file-picker", "file selected", mapOf("name" to file.displayName)) - selectedFile = file - updateSendState { withSelectedFile(file) } - } - - private fun setFilePickerError(reason: String) { - AppLogger.warn("file-picker", "file picker error", mapOf("reason" to reason)) - viewModelScope.launch { repository.setError(reason) } - } - - private fun clearSelectedSource() { - selectedFile = null - updateSendState { - copy( - selectedSource = "", - selectedDisplayName = "", - ) - } - } - - private fun createShare() { - val sendState = state.value.send - if (!sendState.canCreateShare(coreState.value.isInitialized)) return - - viewModelScope.launch { - AppLogger.info("send", "create share requested", mapOf("source" to sendState.selectedSource)) - updateSendState { copy(isSharing = true) } - try { - val file = selectedFile - if (file == null) { - repository.sharePath(sendState.selectedSource, sendState.transferName, sendState.senderName) - } else { - sharePickedFile(repository, file, sendState.transferName, sendState.senderName) - } - repository.state.value.lastShare?.let { share -> - repository.refreshReceiverRequests(share.transferId) - } - } finally { - updateSendState { copy(isSharing = false) } - } - } - } - - private fun useTicketLocally(ticket: String) { - updateReceiveState { copy(ticket = ticket) } - updateAppState { copy(destination = AppDestination.Receive) } - } - - private fun refreshReceiverRequests(transferId: ULong) { - viewModelScope.launch { - repository.refreshReceiverRequests(transferId) - } - } - - private fun respondReceiverRequest(requestId: String, accepted: Boolean) { - viewModelScope.launch { - repository.respondReceiverRequest( - requestId = requestId, - accepted = accepted, - reason = if (accepted) null else "sender-refused", - ) - } - } - - private fun inspectTicket() { - val receiveState = state.value.receive - if (!receiveState.canInspect(coreState.value.isInitialized)) return - - viewModelScope.launch { - repository.inspectTicket(receiveState.ticket) - } - } - - private fun receive() { - val receiveState = state.value.receive - if (!receiveState.canReceive(coreState.value.isInitialized)) return - - viewModelScope.launch { - AppLogger.info("receive", "receive requested") - updateReceiveState { copy(isReceiving = true) } - try { - repository.receive(receiveState.ticket, receiveState.outputDirectory, receiveState.receiverName) - } finally { - updateReceiveState { copy(isReceiving = false) } - } - } - } - - private fun sendEffect(effect: VniDropAppEffect) { - viewModelScope.launch { - effects.send(effect) - } - } - - private fun updateAppState(reducer: AppUiState.() -> AppUiState) { - _state.update { current -> - val next = current.app.reducer() - if (next == current.app) current else current.copy(app = next) - } - } - - private fun updateSendState(reducer: SendUiState.() -> SendUiState) { - _state.update { current -> - val next = current.send.reducer() - if (next == current.send) current else current.copy(send = next) - } - } - - private fun updateReceiveState(reducer: ReceiveUiState.() -> ReceiveUiState) { - _state.update { current -> - val next = current.receive.reducer() - if (next == current.receive) current else current.copy(receive = next) - } - } -} - -private fun SendUiState.withSelectedFile(file: PickedShareFile): SendUiState = - copy( - selectedSource = file.value, - selectedDisplayName = file.displayName, - transferName = if (transferName == DefaultTransferName || transferName.isBlank()) file.displayName else transferName, - ) - -private const val DefaultTransferName = "VniDrop transfer" diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt new file mode 100644 index 0000000..2e62e26 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt @@ -0,0 +1,152 @@ +package com.vnidrop.app.core + +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import uniffi.vnidrop.ReceiveOutputSink + +data class CoreStatus( + val endpointId: String, + val activeTransfers: ULong, + val activeShares: ULong, +) + +data class CoreEventModel( + val id: String, + val timestamp: Long, + val scope: String, + val transferId: ULong?, + val direction: String?, + val phase: String, + val kind: String, + val dataJson: String, +) + +enum class ShareAccessPolicy { + RequireApproval, + AnyoneWithTransfer, +} + +enum class TransferDirection { + Send, + Receive, +} + +enum class TransferStatus { + Importing, + Sharing, + Receiving, + Done, + Failed, + Cancelled, + Stopped, +} + +data class Transfer( + val localId: String, + val transferId: ULong, + val direction: TransferDirection, + val status: TransferStatus, + val peerId: String?, + val transferName: String?, + val contentHash: String?, + val fileCount: ULong, + val totalSize: ULong, + val ticket: String?, + val accessPolicy: ShareAccessPolicy, + val createdAt: Long, + val updatedAt: Long, +) + +data class Share( + val transferId: ULong, + val ticket: String, + val transferName: String, + val contentHash: String, + val fileCount: ULong, + val totalSize: ULong, +) + +data class TransferMetadataModel( + val transferId: ULong, + val transferName: String, + val senderName: String?, + val contentHash: String, + val fileCount: ULong, + val totalSize: ULong, +) + +data class TicketInspectionModel( + val kind: String, + val blobTicket: String, + val metadata: TransferMetadataModel?, +) + +data class ReceiverRequestModel( + val id: String, + val transferId: ULong, + val remoteEndpointId: String, + val transferName: String, + val receiverName: String?, + val receiverDeviceName: String?, + val appVersion: String, + val status: ReceiverDeliveryStatus, + val reason: String?, + val requestedAt: Long, + val respondedAt: Long?, + val completedAt: Long?, +) + +enum class ReceiverDeliveryStatus { + Requested, + Accepted, + Refused, + Expired, + Completed, + Unknown, +} + +data class CoreState( + val isInitialized: Boolean = false, + val status: CoreStatus? = null, + val events: List = emptyList(), + val transfers: List = emptyList(), + val lastShare: Share? = null, + val lastInspection: TicketInspectionModel? = null, +) + +sealed interface CoreSignal { + data class ApprovalChanged(val transferId: ULong) : CoreSignal + data class ReceiverHistoryChanged(val transferId: ULong) : CoreSignal +} + +interface CoreGateway { + val state: StateFlow + val signals: SharedFlow + + suspend fun initialize(appDataDir: String): Result + fun shutdown() + suspend fun sharePath(path: String, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy): Result + suspend fun shareFileDescriptor( + fd: Int, + displayName: String, + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy, + ): Result + suspend fun shareSecurityScopedFileUrl( + fileUrl: String, + displayName: String, + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy, + ): Result + suspend fun inspectTicket(ticket: String): Result + suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result + suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result + suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result + suspend fun cancel(transferId: ULong): Result + suspend fun delete(transferId: ULong): Result + suspend fun receiverRequests(transferId: ULong): Result> + suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null): Result + suspend fun refresh(): Result +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index acb344f..4b0867f 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -1,15 +1,22 @@ package com.vnidrop.app.core import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import kotlin.random.Random import uniffi.vnidrop.CoreEvent import uniffi.vnidrop.CoreEventSink +import uniffi.vnidrop.ReceiveOutputSink import uniffi.vnidrop.ReceiverRequest import uniffi.vnidrop.ShareMetadataInput import uniffi.vnidrop.ShareResult @@ -17,45 +24,56 @@ import uniffi.vnidrop.ShareSource import uniffi.vnidrop.SourceKind import uniffi.vnidrop.StoredTransfer import uniffi.vnidrop.TicketInspection +import uniffi.vnidrop.TransferMetadata +import uniffi.vnidrop.TransferAccessMode import uniffi.vnidrop.VnidropCore -data class CoreUiState( - val isInitialized: Boolean = false, - val status: String = "Not initialized", - val events: List = emptyList(), - val transfers: List = emptyList(), - val lastShare: ShareResult? = null, - val lastInspection: TicketInspection? = null, - val receiverRequests: List = emptyList(), - val error: String? = null, -) - class CoreRepository( private val dispatcher: CoroutineDispatcher = Dispatchers.IO, -) { - private val _state = MutableStateFlow(CoreUiState()) - val state: StateFlow = _state +) : CoreGateway { + private val _state = MutableStateFlow(CoreState()) + override val state: StateFlow = _state.asStateFlow() + + private val _signals = MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + override val signals: SharedFlow = _signals.asSharedFlow() private var core: VnidropCore? = null private val sink = object : CoreEventSink { override fun onEvent(event: CoreEvent) { - _state.update { current -> - current.copy(events = (listOf(event) + current.events).take(200)) + val model = event.toModel() + _state.update { current -> current.copy(events = (listOf(model) + current.events).take(MaxEvents)) } + if (model.phase == "approval" && model.transferId != null) { + _signals.tryEmit(CoreSignal.ApprovalChanged(model.transferId)) + } + if (model.phase == "delivery" && model.transferId != null) { + _signals.tryEmit(CoreSignal.ReceiverHistoryChanged(model.transferId)) } } } - suspend fun initialize(appDataDir: String) = runCore { + override suspend fun initialize(appDataDir: String): Result = runCore { core?.shutdown() core = VnidropCore.initialize(appDataDir, sink) - refreshStatus() - loadTransfers() - loadEvents() - _state.update { it.copy(isInitialized = true, error = null) } + refreshSnapshot() + _state.update { it.copy(isInitialized = true) } } - suspend fun sharePath(path: String, transferName: String, senderName: String) = runCore { + override fun shutdown() { + core?.shutdown() + core = null + _state.value = CoreState() + } + + override suspend fun sharePath( + path: String, + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy, + ): Result = shareSources( sources = listOf( ShareSource( @@ -67,18 +85,16 @@ class CoreRepository( ), transferName = transferName, senderName = senderName, + accessPolicy = accessPolicy, ) - } - suspend fun shareFileDescriptor( + override suspend fun shareFileDescriptor( fd: Int, displayName: String, transferName: String, senderName: String, - ) = runCore { - // The fd is borrowed from platform code. Rust duplicates it before - // starting the import, so Android may close the ParcelFileDescriptor - // once this suspend call returns. + accessPolicy: ShareAccessPolicy, + ): Result = shareSources( sources = listOf( ShareSource( @@ -90,17 +106,16 @@ class CoreRepository( ), transferName = transferName, senderName = senderName, + accessPolicy = accessPolicy, ) - } - suspend fun shareSecurityScopedFileUrl( + override suspend fun shareSecurityScopedFileUrl( fileUrl: String, displayName: String, transferName: String, senderName: String, - ) = runCore { - // The iOS actual for withPlatformPathAccess starts and stops the - // security-scoped URL lease around this entire shareFiles call. + accessPolicy: ShareAccessPolicy, + ): Result = shareSources( sources = listOf( ShareSource( @@ -112,94 +127,86 @@ class CoreRepository( ), transferName = transferName, senderName = senderName, + accessPolicy = accessPolicy, ) + + override suspend fun inspectTicket(ticket: String): Result = runCore { + requireCore().inspectTicket(ticket).toModel().also { inspection -> + _state.update { it.copy(lastInspection = inspection) } + } } - suspend fun inspectTicket(ticket: String) = runCore { - val inspection = requireCore().inspectTicket(ticket) - _state.update { it.copy(lastInspection = inspection, error = null) } - } - - suspend fun receive(ticket: String, outputDir: String, receiverName: String) = runCore { + override suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result = runCore { requireCore().receive(ticket, outputDir, receiverName.ifBlank { null }) - refreshStatus() - loadTransfers() + refreshSnapshot() } - suspend fun receiveIntoSecurityScopedDirectory( + override suspend fun receiveWithOutputSink( + ticket: String, + outputSink: ReceiveOutputSink, + receiverName: String, + ): Result = runCore { + requireCore().receiveWithOutputSink(ticket, outputSink, receiverName.ifBlank { null }) + refreshSnapshot() + } + + override suspend fun receiveIntoSecurityScopedDirectory( ticket: String, outputDirectoryUrl: String, receiverName: String, - ) = runCore { + ): Result = runCore { withPlatformPathAccess(SourceKind.IOS_SECURITY_SCOPED_URL, outputDirectoryUrl) { requireCore().receive(ticket, outputDirectoryUrl, receiverName.ifBlank { null }) } - refreshStatus() - loadTransfers() + refreshSnapshot() } - suspend fun cancel(transferId: ULong) = runCore { + override suspend fun cancel(transferId: ULong): Result = runCore { requireCore().cancelTransfer(transferId) - refreshStatus() - loadTransfers() + refreshSnapshot() } - suspend fun refreshReceiverRequests(transferId: ULong) = runCore { - val requests = requireCore().listReceiverRequests(transferId) - _state.update { it.copy(receiverRequests = requests, error = null) } + override suspend fun delete(transferId: ULong): Result = runCore { + requireCore().deleteTransfer(transferId) + refreshSnapshot() + _signals.tryEmit(CoreSignal.ApprovalChanged(transferId)) + _signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId)) } - suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null) = runCore { + override suspend fun receiverRequests(transferId: ULong): Result> = runCore { + requireCore().listReceiverRequests(transferId).map(ReceiverRequest::toModel) + } + + override suspend fun respondReceiverRequest( + requestId: String, + accepted: Boolean, + reason: String?, + ): Result = 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) { - _state.update { it.copy(error = message) } - } - - private suspend fun runCore(block: suspend () -> Unit) { - withContext(dispatcher) { - try { - block() - } catch (error: Throwable) { - _state.update { it.copy(error = error.message ?: error.toString()) } - } - } - } - - private fun requireCore(): VnidropCore = - core ?: error("Initialize the core first.") + override suspend fun refresh(): Result = runCore { refreshSnapshot() } private suspend fun shareSources( sources: List, transferName: String, senderName: String, - ) { + accessPolicy: ShareAccessPolicy, + ): Result = runCore { withPlatformPathAccess(sources) { - val result = requireCore().shareFiles( + requireCore().shareFiles( sources = sources, metadata = ShareMetadataInput( transferId = nextTransferId(), transferName = transferName.ifBlank { null }, senderName = senderName.ifBlank { null }, + accessMode = accessPolicy.toNative(), ), - ) - _state.update { it.copy(lastShare = result, receiverRequests = emptyList(), error = null) } + ).toModel() + }.also { share -> + refreshSnapshot() + _state.update { it.copy(lastShare = share) } } - refreshStatus() - loadTransfers() } private suspend fun withPlatformPathAccess( @@ -207,36 +214,140 @@ class CoreRepository( index: Int = 0, block: suspend () -> T, ): T { - if (index >= sources.size) { - return block() - } + if (index >= sources.size) return block() val source = sources[index] return withPlatformPathAccess(source.kind, source.value) { withPlatformPathAccess(sources, index + 1, block) } } - private fun refreshStatus() { - val status = core?.status() + private fun refreshSnapshot() { + val activeCore = requireCore() + val status = activeCore.status() _state.update { it.copy( - status = status?.let { value -> - "Endpoint ${value.endpointId.take(12)}... | active=${value.activeTransfers} shares=${value.activeShares}" - } ?: "Not initialized", + status = CoreStatus(status.endpointId, status.activeTransfers, status.activeShares), + transfers = activeCore.listTransfers().map(StoredTransfer::toModel), + events = activeCore.listEvents(null).map(CoreEvent::toModel).take(MaxEvents), ) } } - private fun loadTransfers() { - val transfers = core?.listTransfers().orEmpty() - _state.update { it.copy(transfers = transfers, error = null) } - } + private suspend fun runCore(block: suspend () -> T): Result = + withContext(dispatcher) { + try { + Result.success(block()) + } catch (error: Throwable) { + if (error is CancellationException) throw error + Result.failure(error) + } + } - private fun loadEvents() { - val events = core?.listEvents(null).orEmpty() - _state.update { it.copy(events = events.take(200), error = null) } - } + private fun requireCore(): VnidropCore = core ?: error("Initialize the core first.") - private fun nextTransferId(): ULong = - Random.nextLong(1, Long.MAX_VALUE).toULong() + private fun nextTransferId(): ULong = Random.nextLong(1, Long.MAX_VALUE).toULong() + + private companion object { + const val MaxEvents = 200 + } } + +private fun CoreEvent.toModel(): CoreEventModel = CoreEventModel( + id = id, + timestamp = timestamp, + scope = scope, + transferId = transferId, + direction = direction, + phase = phase, + kind = kind, + dataJson = dataJson, +) + +private fun StoredTransfer.toModel(): Transfer = Transfer( + localId = localId, + transferId = transferId, + direction = direction.toTransferDirection(), + status = status.toTransferStatus(), + peerId = peerId, + transferName = transferName, + contentHash = contentHash, + fileCount = fileCount, + totalSize = totalSize, + ticket = ticket, + accessPolicy = accessMode.toModel(), + createdAt = createdAt, + updatedAt = updatedAt, +) + +private fun ShareAccessPolicy.toNative(): TransferAccessMode = when (this) { + ShareAccessPolicy.RequireApproval -> TransferAccessMode.APPROVAL_REQUIRED + ShareAccessPolicy.AnyoneWithTransfer -> TransferAccessMode.PUBLIC +} + +private fun TransferAccessMode.toModel(): ShareAccessPolicy = when (this) { + TransferAccessMode.APPROVAL_REQUIRED -> ShareAccessPolicy.RequireApproval + TransferAccessMode.PUBLIC -> ShareAccessPolicy.AnyoneWithTransfer +} + +private fun String.toTransferDirection(): TransferDirection = when (this) { + "send" -> TransferDirection.Send + "receive" -> TransferDirection.Receive + else -> error("Unknown transfer direction: $this") +} + +private fun String.toTransferStatus(): TransferStatus = when (this) { + "importing" -> TransferStatus.Importing + "sharing" -> TransferStatus.Sharing + "receiving" -> TransferStatus.Receiving + "done" -> TransferStatus.Done + "failed" -> TransferStatus.Failed + "cancelled" -> TransferStatus.Cancelled + "stopped" -> TransferStatus.Stopped + else -> error("Unknown transfer status: $this") +} + +private fun ShareResult.toModel(): Share = Share( + transferId = transferId, + ticket = ticket, + transferName = transferName, + contentHash = hash, + fileCount = fileCount, + totalSize = totalSize, +) + +private fun TicketInspection.toModel(): TicketInspectionModel = TicketInspectionModel( + kind = kind, + blobTicket = blobTicket, + metadata = metadata?.toModel(), +) + +private fun TransferMetadata.toModel(): TransferMetadataModel = TransferMetadataModel( + transferId = transferId, + transferName = transferName, + senderName = senderName, + contentHash = contentHash, + fileCount = fileCount, + totalSize = totalSize, +) + +private fun ReceiverRequest.toModel(): ReceiverRequestModel = ReceiverRequestModel( + id = id, + transferId = transferId, + remoteEndpointId = remoteEndpointId, + transferName = transferName, + receiverName = receiverName, + receiverDeviceName = receiverDeviceName, + appVersion = appVersion, + status = when (status) { + "requested" -> ReceiverDeliveryStatus.Requested + "accepted" -> ReceiverDeliveryStatus.Accepted + "refused" -> ReceiverDeliveryStatus.Refused + "expired" -> ReceiverDeliveryStatus.Expired + "completed" -> ReceiverDeliveryStatus.Completed + else -> ReceiverDeliveryStatus.Unknown + }, + reason = reason, + requestedAt = requestedAt, + respondedAt = respondedAt, + completedAt = completedAt, +) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt index bce6356..4d0b710 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt @@ -5,21 +5,26 @@ import androidx.compose.runtime.Composable data class PickedShareFile( val value: String, val displayName: String, + val sizeBytes: ULong? = null, + val thumbnailBytes: ByteArray? = null, ) interface ShareFilePicker { fun pickFile() } +interface ReceiveFolderPicker { + fun pickFolder() +} + @Composable expect fun rememberShareFilePicker( onFilePicked: (PickedShareFile) -> Unit, onError: (String) -> Unit, ): ShareFilePicker -expect suspend fun sharePickedFile( - repository: CoreRepository, - file: PickedShareFile, - transferName: String, - senderName: String, -) +@Composable +expect fun rememberReceiveFolderPicker( + onFolderPicked: (ReceiveFolder) -> Unit, + onError: (String) -> Unit, +): ReceiveFolderPicker diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt new file mode 100644 index 0000000..0b849db --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt @@ -0,0 +1,41 @@ +package com.vnidrop.app.core + +import androidx.compose.runtime.Composable +import uniffi.vnidrop.ReceiveOutputSink + +enum class ReceiveFolderKind { + FileSystemPath, + AndroidTreeUri, + IosSecurityScopedUrl, +} + +data class ReceiveFolder( + val kind: ReceiveFolderKind, + val value: String, + val displayName: String, +) + +enum class FolderAccessStatus { + Writable, + PermissionRequired, + Unavailable, +} + +interface FileSystemService { + fun defaultReceiveFolder(): ReceiveFolder + suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus + fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? + suspend fun sharePickedFile( + repository: CoreGateway, + file: PickedShareFile, + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy, + ): Result +} + +@Composable +expect fun rememberFileSystemService(): FileSystemService + +fun ReceiveFolder.isFileSystemPath(): Boolean = + kind == ReceiveFolderKind.FileSystemPath diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.kt index 068da8a..f120814 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.kt @@ -3,7 +3,7 @@ package com.vnidrop.app.core import uniffi.vnidrop.SourceKind // Platform file handles have different lifetime rules. Desktop paths need no -// extra work, Android fd sources are duplicated immediately by Rust, and iOS +// extra work, Rust duplicates Android fd sources immediately, and iOS // security-scoped URLs must remain leased while Rust performs the blocking // import/export call. internal expect suspend fun withPlatformPathAccess( diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/app/AppViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/app/AppViewModel.kt new file mode 100644 index 0000000..2e0f2dc --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/app/AppViewModel.kt @@ -0,0 +1,58 @@ +package com.vnidrop.app.feature.app + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vnidrop.app.PlatformEnvironment +import com.vnidrop.app.AppDependencies +import com.vnidrop.app.AppGraph +import com.vnidrop.app.core.CoreGateway +import com.vnidrop.app.logging.AppLogger +import com.vnidrop.app.preferences.PreferencesRepository +import com.vnidrop.app.ui.feedback.UiMessageController +import com.vnidrop.app.ui.navigation.AppDestination +import com.vnidrop.app.ui.theme.ThemeMode +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class AppState( + val destination: AppDestination = AppDestination.Send, + val themeMode: ThemeMode = ThemeMode.System, +) + +class AppGraphViewModel(dependencies: AppDependencies) : ViewModel() { + val graph = AppGraph(dependencies) + + override fun onCleared() { + graph.close() + super.onCleared() + } +} + +class AppViewModel( + private val environment: PlatformEnvironment, + private val repository: CoreGateway, + preferencesRepository: PreferencesRepository, + private val messages: UiMessageController, +) : ViewModel() { + private val _state = MutableStateFlow(AppState()) + val state: StateFlow = _state.asStateFlow() + + init { + AppLogger.info("lifecycle", "app started", mapOf("platform" to environment.name)) + viewModelScope.launch { + repository.initialize(environment.defaultCoreDataDir).onFailure(messages::error) + } + viewModelScope.launch { + preferencesRepository.preferences.collect { preferences -> + _state.update { it.copy(themeMode = preferences.themeMode) } + } + } + } + + fun selectDestination(destination: AppDestination) { + _state.update { it.copy(destination = destination) } + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt new file mode 100644 index 0000000..54c0871 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt @@ -0,0 +1,169 @@ +package com.vnidrop.app.feature.approvals + +import com.vnidrop.app.core.CoreGateway +import com.vnidrop.app.core.CoreSignal +import com.vnidrop.app.core.TransferDirection +import com.vnidrop.app.core.TransferStatus +import com.vnidrop.app.core.ReceiverRequestModel +import com.vnidrop.app.core.ReceiverDeliveryStatus +import com.vnidrop.app.notifications.LocalNotification +import com.vnidrop.app.notifications.LocalNotificationService +import com.vnidrop.app.notifications.NotificationPermission +import com.vnidrop.app.platform.AppVisibility +import com.vnidrop.app.preferences.PreferencesRepository +import com.vnidrop.app.ui.feedback.UiMessageController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class PendingApproval( + val id: String, + val transferId: ULong, + val transferName: String, + val receiverName: String?, + val receiverDeviceName: String?, + val requestedAt: Long, +) + +data class ApprovalState( + val pending: List = emptyList(), + val respondingIds: Set = emptySet(), +) { + val current: PendingApproval? + get() = pending.firstOrNull() +} + +class ApprovalCoordinator( + private val repository: CoreGateway, + private val preferencesRepository: PreferencesRepository, + private val notifications: LocalNotificationService, + private val visibility: AppVisibility, + private val messages: UiMessageController, + private val scope: CoroutineScope, +) { + private val _state = MutableStateFlow(ApprovalState()) + val state: StateFlow = _state.asStateFlow() + + private val publishedNotificationIds = mutableSetOf() + + init { + scope.launch { + repository.signals.collect { signal -> + when (signal) { + is CoreSignal.ApprovalChanged -> refresh(signal.transferId) + is CoreSignal.ReceiverHistoryChanged -> Unit + } + } + } + scope.launch { + repository.state.collectLatest { core -> + if (core.isInitialized) { + core.transfers + .filter { it.direction == TransferDirection.Send && it.status == TransferStatus.Sharing } + .forEach { refresh(it.transferId) } + } + } + } + scope.launch { + combine( + preferencesRepository.preferences, + visibility.isForeground, + state, + notifications.permission, + ) { preferences, foreground, approvalState, permission -> + NotificationContext(preferences.notificationsEnabled, foreground, approvalState.pending, permission) + }.collect { context -> + synchronizeNotifications(context) + } + } + } + + fun accept(requestId: String) = respond(requestId, accepted = true) + + fun refuse(requestId: String) = respond(requestId, accepted = false) + + private fun respond(requestId: String, accepted: Boolean) { + if (!_state.value.respondingIds.addable(requestId)) return + _state.update { it.copy(respondingIds = it.respondingIds + requestId) } + scope.launch { + val request = _state.value.pending.firstOrNull { it.id == requestId } + val result = repository.respondReceiverRequest( + requestId = requestId, + accepted = accepted, + reason = if (accepted) null else "sender-refused", + ) + _state.update { it.copy(respondingIds = it.respondingIds - requestId) } + result.fold( + onSuccess = { + if (request != null) refresh(request.transferId) + }, + onFailure = messages::error, + ) + } + } + + private suspend fun refresh(transferId: ULong) { + repository.receiverRequests(transferId).fold( + onSuccess = { requests -> + val refreshed = requests.filter { it.status == ReceiverDeliveryStatus.Requested }.map(ReceiverRequestModel::toPending) + val removed = _state.value.pending.filter { it.transferId == transferId }.map { it.id }.toSet() - refreshed.map { it.id }.toSet() + removed.forEach { id -> + notifications.cancel(notificationId(id)) + publishedNotificationIds.remove(id) + } + _state.update { current -> + current.copy( + pending = (current.pending.filterNot { it.transferId == transferId } + refreshed) + .distinctBy(PendingApproval::id) + .sortedBy(PendingApproval::requestedAt), + ) + } + }, + onFailure = messages::error, + ) + } + + private suspend fun synchronizeNotifications(context: NotificationContext) { + if (context.foreground || !context.enabled || context.permission != NotificationPermission.Granted) { + notifications.cancelAll() + return + } + context.pending.filterNot { it.id in publishedNotificationIds }.forEach { request -> + val receiver = request.receiverName ?: request.receiverDeviceName ?: "A nearby device" + notifications.publish( + LocalNotification( + id = notificationId(request.id), + title = "Connection request", + body = "$receiver wants to receive ${request.transferName}", + ), + ).onSuccess { + publishedNotificationIds += request.id + }.onFailure(messages::error) + } + } +} + +private data class NotificationContext( + val enabled: Boolean, + val foreground: Boolean, + val pending: List, + val permission: NotificationPermission, +) + +private fun ReceiverRequestModel.toPending(): PendingApproval = PendingApproval( + id = id, + transferId = transferId, + transferName = transferName, + receiverName = receiverName, + receiverDeviceName = receiverDeviceName, + requestedAt = requestedAt, +) + +private fun Set.addable(value: String): Boolean = value !in this + +private fun notificationId(requestId: String): String = "approval-$requestId" diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalModalHost.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalModalHost.kt new file mode 100644 index 0000000..d395624 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalModalHost.kt @@ -0,0 +1,122 @@ +package com.vnidrop.app.feature.approvals + +import androidx.compose.foundation.layout.Arrangement +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.vnidrop.app.ui.components.PrimaryButton +import com.vnidrop.app.ui.components.SecondaryButton +import com.vnidrop.app.ui.theme.LocalVniDropColors +import org.jetbrains.compose.resources.stringResource +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.approval_connection_request +import vnidrop.shared.generated.resources.approval_pending_count +import vnidrop.shared.generated.resources.button_approve +import vnidrop.shared.generated.resources.button_refuse + +@Composable +fun ApprovalModalHost( + state: ApprovalState, + onAccept: (String) -> Unit, + onRefuse: (String) -> Unit, +) { + val request = state.current ?: return + val busy = request.id in state.respondingIds + val receiver = request.receiverName ?: request.receiverDeviceName ?: "A nearby device" + val colors = LocalVniDropColors.current + Dialog( + onDismissRequest = {}, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + usePlatformDefaultWidth = false, + ), + ) { + Surface( + modifier = Modifier.padding(24.dp).widthIn(max = 440.dp).fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + color = colors.backgroundDialog, + shadowElevation = 16.dp, + ) { + Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Surface(shape = RoundedCornerShape(14.dp), color = colors.backgroundSelection) { + Icon( + ApprovalIcon, + contentDescription = null, + tint = colors.brandLink, + modifier = Modifier.padding(11.dp).size(24.dp), + ) + } + Text( + stringResource(Res.string.approval_connection_request), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Text( + "$receiver wants to receive ${request.transferName}.", + style = MaterialTheme.typography.bodyLarge, + color = colors.foregroundLight, + ) + if (state.pending.size > 1) { + Text( + stringResource(Res.string.approval_pending_count, state.pending.size), + style = MaterialTheme.typography.bodySmall, + color = colors.foregroundLighter, + ) + } + BoxWithConstraints(Modifier.fillMaxWidth()) { + if (maxWidth < 330.dp) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + PrimaryButton(stringResource(Res.string.button_approve), { onAccept(request.id) }, Modifier.fillMaxWidth(), !busy) + SecondaryButton(stringResource(Res.string.button_refuse), { onRefuse(request.id) }, Modifier.fillMaxWidth(), !busy) + } + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + Spacer(Modifier.weight(1f)) + SecondaryButton(stringResource(Res.string.button_refuse), { onRefuse(request.id) }, enabled = !busy) + Spacer(Modifier.width(10.dp)) + PrimaryButton(stringResource(Res.string.button_approve), { onAccept(request.id) }, enabled = !busy) + } + } + } + } + } + } +} + +private val ApprovalIcon: ImageVector = ImageVector.Builder( + name = "Approval", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, +).apply { + path { + moveTo(12f, 2f); lineTo(20f, 5.5f); verticalLineTo(11f) + curveTo(20f, 16.1f, 16.6f, 20.7f, 12f, 22f) + curveTo(7.4f, 20.7f, 4f, 16.1f, 4f, 11f); verticalLineTo(5.5f); close() + moveTo(8.2f, 11.8f); lineTo(10.7f, 14.3f); lineTo(15.9f, 9.1f) + } +}.build() diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveRoute.kt new file mode 100644 index 0000000..c6f2df9 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveRoute.kt @@ -0,0 +1,19 @@ +package com.vnidrop.app.feature.receive + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +@Composable +fun ReceiveRoute(viewModel: ReceiveViewModel) { + val state by viewModel.state.collectAsStateWithLifecycle() + val coreState by viewModel.coreState.collectAsStateWithLifecycle() + ReceiveScreen( + coreState = coreState, + state = state, + onTicketChanged = viewModel::setTicket, + onReceiverNameChanged = viewModel::setReceiverName, + onInspectTicket = viewModel::inspectTicket, + onReceive = viewModel::receive, + ) +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt new file mode 100644 index 0000000..cb8dc74 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt @@ -0,0 +1,76 @@ +package com.vnidrop.app.feature.receive + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import com.vnidrop.app.core.CoreState +import com.vnidrop.app.core.FolderAccessStatus +import com.vnidrop.app.ui.components.AppCard +import com.vnidrop.app.ui.components.Field +import com.vnidrop.app.ui.components.MetadataRow +import com.vnidrop.app.ui.components.PrimaryButton +import com.vnidrop.app.ui.components.SecondaryButton +import com.vnidrop.app.ui.screens.ProgressSection +import com.vnidrop.app.ui.screens.ScreenHeader +import com.vnidrop.app.ui.screens.TicketInspectionCard +import org.jetbrains.compose.resources.stringResource +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.button_inspect_ticket +import vnidrop.shared.generated.resources.button_receive +import vnidrop.shared.generated.resources.button_receiving +import vnidrop.shared.generated.resources.field_output_directory +import vnidrop.shared.generated.resources.field_receiver_name +import vnidrop.shared.generated.resources.field_ticket +import vnidrop.shared.generated.resources.folder_status_permission_required +import vnidrop.shared.generated.resources.folder_status_unavailable +import vnidrop.shared.generated.resources.folder_status_writable +import vnidrop.shared.generated.resources.metadata_status +import vnidrop.shared.generated.resources.receive_subtitle +import vnidrop.shared.generated.resources.receive_title +import vnidrop.shared.generated.resources.ticket_card_title + +@Composable +fun ReceiveScreen( + coreState: CoreState, + state: ReceiveState, + onTicketChanged: (String) -> Unit, + onReceiverNameChanged: (String) -> Unit, + onInspectTicket: () -> Unit, + onReceive: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + ScreenHeader(stringResource(Res.string.receive_title), stringResource(Res.string.receive_subtitle)) + AppCard(title = stringResource(Res.string.ticket_card_title)) { + Field(state.ticket, onTicketChanged, stringResource(Res.string.field_ticket), minLines = 4) + MetadataRow( + stringResource(Res.string.field_output_directory), + state.receiveFolder?.displayName?.ifBlank { state.outputDirectory } ?: state.outputDirectory, + ) + MetadataRow(stringResource(Res.string.metadata_status), state.folderAccessStatus.displayName()) + Field(state.receiverName, onReceiverNameChanged, stringResource(Res.string.field_receiver_name)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + SecondaryButton( + stringResource(Res.string.button_inspect_ticket), + onClick = onInspectTicket, + enabled = state.canInspect(coreState.isInitialized), + ) + PrimaryButton( + if (state.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive), + onClick = onReceive, + enabled = state.canReceive(coreState.isInitialized), + ) + } + } + coreState.lastInspection?.let { TicketInspectionCard(it) } + ProgressSection(coreState) + } +} + +@Composable +private fun FolderAccessStatus.displayName(): String = when (this) { + FolderAccessStatus.Writable -> stringResource(Res.string.folder_status_writable) + FolderAccessStatus.PermissionRequired -> stringResource(Res.string.folder_status_permission_required) + FolderAccessStatus.Unavailable -> stringResource(Res.string.folder_status_unavailable) +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt new file mode 100644 index 0000000..588e3cb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt @@ -0,0 +1,91 @@ +package com.vnidrop.app.feature.receive + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vnidrop.app.core.CoreGateway +import com.vnidrop.app.core.FileSystemService +import com.vnidrop.app.core.FolderAccessStatus +import com.vnidrop.app.core.ReceiveFolder +import com.vnidrop.app.core.ReceiveFolderKind +import com.vnidrop.app.preferences.PreferencesRepository +import com.vnidrop.app.ui.feedback.UiMessageController +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class ReceiveState( + val ticket: String = "", + val outputDirectory: String = "", + val receiverName: String = "", + val receiveFolder: ReceiveFolder? = null, + val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable, + val isReceiving: Boolean = false, +) { + fun canInspect(coreInitialized: Boolean): Boolean = coreInitialized && ticket.isNotBlank() + fun canReceive(coreInitialized: Boolean): Boolean = + coreInitialized && ticket.isNotBlank() && outputDirectory.isNotBlank() && + folderAccessStatus == FolderAccessStatus.Writable && !isReceiving +} + +class ReceiveViewModel( + private val repository: CoreGateway, + private val fileSystemService: FileSystemService, + preferencesRepository: PreferencesRepository, + private val messages: UiMessageController, +) : ViewModel() { + private val _state = MutableStateFlow(ReceiveState()) + val state: StateFlow = _state.asStateFlow() + val coreState = repository.state + + init { + viewModelScope.launch { + preferencesRepository.preferences.collect { preferences -> + val status = fileSystemService.validateReceiveFolder(preferences.receiveFolder) + _state.update { current -> + current.copy( + receiverName = current.receiverName.ifBlank { preferences.username }, + receiveFolder = preferences.receiveFolder, + outputDirectory = preferences.receiveFolder.value, + folderAccessStatus = status, + ) + } + } + } + } + + fun setTicket(value: String) = _state.update { it.copy(ticket = value) } + fun setOutputDirectory(value: String) = _state.update { it.copy(outputDirectory = value) } + fun setReceiverName(value: String) = _state.update { it.copy(receiverName = value) } + + fun inspectTicket() { + val current = state.value + if (!current.canInspect(coreState.value.isInitialized)) return + viewModelScope.launch { repository.inspectTicket(current.ticket).onFailure(messages::error) } + } + + fun receive() { + val current = state.value + val folder = current.receiveFolder ?: return + if (!current.canReceive(coreState.value.isInitialized)) return + viewModelScope.launch { + _state.update { it.copy(isReceiving = true) } + try { + val outputSink = fileSystemService.createReceiveOutputSink(folder) + val result = when { + outputSink != null -> repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName) + folder.kind == ReceiveFolderKind.IosSecurityScopedUrl -> repository.receiveIntoSecurityScopedDirectory( + current.ticket, + folder.value, + current.receiverName, + ) + else -> repository.receive(current.ticket, current.outputDirectory, current.receiverName) + } + result.onFailure(messages::error) + } finally { + _state.update { it.copy(isReceiving = false) } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/FilePreviewRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/FilePreviewRepository.kt new file mode 100644 index 0000000..61c3459 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/FilePreviewRepository.kt @@ -0,0 +1,111 @@ +package com.vnidrop.app.feature.send + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +data class PreviewFileInfo( + val transferId: ULong, + val byteSize: Long, + val modifiedAtMillis: Long, +) + +interface PlatformPreviewStore { + fun list(): List + fun read(transferId: ULong): ByteArray? + fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean + fun delete(transferId: ULong) +} + +expect fun createPlatformPreviewStore(appDataDir: String): PlatformPreviewStore + +interface FilePreviewRepository { + val previews: StateFlow> + suspend fun restore(activeTransferIds: Set) + suspend fun save(transferId: ULong, bytes: ByteArray) + suspend fun remove(transferId: ULong) +} + +data class PreviewStoragePolicy( + val maxEntryBytes: Int = 512 * 1024, + val maxTotalBytes: Long = 20L * 1024L * 1024L, +) { + init { + require(maxEntryBytes > 0) + require(maxTotalBytes >= maxEntryBytes) + } +} + +class AppFilePreviewRepository( + private val store: PlatformPreviewStore, + private val policy: PreviewStoragePolicy = PreviewStoragePolicy(), +) : FilePreviewRepository { + private val mutex = Mutex() + private val _previews = MutableStateFlow>(emptyMap()) + override val previews: StateFlow> = _previews.asStateFlow() + + override suspend fun restore(activeTransferIds: Set) = withContext(Dispatchers.Default) { + mutex.withLock { + val files = store.list() + for (file in files) { + if (file.transferId !in activeTransferIds || file.byteSize !in 1..policy.maxEntryBytes.toLong()) { + store.delete(file.transferId) + } + } + enforceQuota() + _previews.value = store.list() + .filter { it.transferId in activeTransferIds } + .mapNotNull { file -> + store.read(file.transferId) + ?.takeIf { it.isSupportedPreview() && it.size <= policy.maxEntryBytes } + ?.let { file.transferId to it } + ?: run { + store.delete(file.transferId) + null + } + } + .toMap() + } + } + + override suspend fun save(transferId: ULong, bytes: ByteArray) = withContext(Dispatchers.Default) { + mutex.withLock { + if (bytes.size !in 1..policy.maxEntryBytes || !bytes.isSupportedPreview()) return@withLock + if (!store.writeAtomically(transferId, bytes)) return@withLock + enforceQuota(protectedTransferId = transferId) + if (store.read(transferId) != null) { + _previews.value = _previews.value + (transferId to bytes.copyOf()) + } + } + } + + override suspend fun remove(transferId: ULong) = withContext(Dispatchers.Default) { + mutex.withLock { + store.delete(transferId) + _previews.value = _previews.value - transferId + } + } + + private fun enforceQuota(protectedTransferId: ULong? = null) { + val files = store.list().sortedBy { it.modifiedAtMillis } + var total = files.sumOf(PreviewFileInfo::byteSize) + for (file in files) { + if (total <= policy.maxTotalBytes) break + if (file.transferId == protectedTransferId) continue + store.delete(file.transferId) + total -= file.byteSize + _previews.value = _previews.value - file.transferId + } + } +} + +private fun ByteArray.isSupportedPreview(): Boolean { + val png = size >= 8 && this[0] == 0x89.toByte() && decodeToString(1, 4) == "PNG" + val jpeg = size >= 3 && this[0] == 0xff.toByte() && this[1] == 0xd8.toByte() && this[2] == 0xff.toByte() + val webp = size >= 12 && decodeToString(0, 4) == "RIFF" && decodeToString(8, 12) == "WEBP" + return png || jpeg || webp +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt new file mode 100644 index 0000000..ad7083d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt @@ -0,0 +1,221 @@ +package com.vnidrop.app.feature.send + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +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.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vnidrop.app.core.Transfer +import com.vnidrop.app.core.TransferStatus +import com.vnidrop.app.ui.components.PillTone +import com.vnidrop.app.ui.components.PrimaryButton +import com.vnidrop.app.ui.components.StatusPill +import com.vnidrop.app.ui.state.WindowClass +import com.vnidrop.app.ui.state.displayNameForStatus +import com.vnidrop.app.ui.state.formatBytes +import com.vnidrop.app.ui.theme.LocalVniDropColors +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.resources.decodeToImageBitmap +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.button_create_new_transfer +import vnidrop.shared.generated.resources.send_empty_body +import vnidrop.shared.generated.resources.send_empty_title +import vnidrop.shared.generated.resources.send_new_transfer_description +import vnidrop.shared.generated.resources.send_new_transfer_title +import vnidrop.shared.generated.resources.send_subtitle +import vnidrop.shared.generated.resources.send_title +import vnidrop.shared.generated.resources.send_transfers_title + +@Composable +internal fun SendFloatingAction(onClick: () -> Unit, modifier: Modifier = Modifier) { + FloatingActionButton( + onClick = onClick, + modifier = modifier, + containerColor = LocalVniDropColors.current.brandButton, + contentColor = Color.White, + ) { + Icon(SendIcons.Plus, contentDescription = stringResource(Res.string.send_new_transfer_description)) + } +} + +@Composable +internal fun TransferCatalog( + transfers: List, + transferThumbnails: Map, + windowClass: WindowClass, + onOpenComposer: () -> Unit, + onTransferSelected: (ULong) -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize().statusBarsPadding(), + contentPadding = PaddingValues( + start = 16.dp, + top = 16.dp, + end = 16.dp, + bottom = if (windowClass == WindowClass.Phone && transfers.isNotEmpty()) 96.dp else 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { CatalogHeader(showAction = windowClass != WindowClass.Phone && transfers.isNotEmpty(), onOpenComposer) } + if (transfers.isEmpty()) { + item { SendEmptyState(onOpenComposer) } + } else { + item { + Text( + stringResource(Res.string.send_transfers_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + items(transfers, key = Transfer::localId) { transfer -> + TransferListItem(transfer, transferThumbnails[transfer.transferId]) { onTransferSelected(transfer.transferId) } + } + } + } +} + +@Composable +private fun CatalogHeader(showAction: Boolean, onOpenComposer: () -> Unit) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(Res.string.send_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text( + stringResource(Res.string.send_subtitle), + color = LocalVniDropColors.current.foregroundLighter, + style = MaterialTheme.typography.bodyMedium, + ) + } + if (showAction) { + Spacer(Modifier.width(16.dp)) + PrimaryButton(stringResource(Res.string.button_create_new_transfer), onClick = onOpenComposer) + } + } +} + +@Composable +private fun SendEmptyState(onOpenComposer: () -> Unit) { + val colors = LocalVniDropColors.current + Column( + modifier = Modifier.fillMaxWidth().heightIn(min = 430.dp).padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Box( + modifier = Modifier.size(68.dp).clip(RoundedCornerShape(22.dp)).background(colors.brandLink.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, + ) { + Icon(SendIcons.File, contentDescription = null, tint = colors.brandLink, modifier = Modifier.size(30.dp)) + } + Text( + stringResource(Res.string.send_empty_title), + modifier = Modifier.padding(top = 22.dp), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + Text( + stringResource(Res.string.send_empty_body), + modifier = Modifier.padding(top = 8.dp).widthIn(max = 480.dp), + color = colors.foregroundLighter, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + PrimaryButton( + stringResource(Res.string.button_create_new_transfer), + onClick = onOpenComposer, + modifier = Modifier.padding(top = 22.dp), + ) + } +} + +@Composable +private fun TransferListItem(transfer: Transfer, thumbnailBytes: ByteArray?, onClick: () -> Unit) { + val colors = LocalVniDropColors.current + Surface(onClick = onClick, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = colors.backgroundSurface200) { + Row(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier.size(44.dp).background(colors.backgroundSurface300, RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center, + ) { + FileArtwork(thumbnailBytes, Modifier.fillMaxSize()) + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + transfer.transferName ?: stringResource(Res.string.send_new_transfer_title), + modifier = Modifier.weight(1f, fill = false), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.width(8.dp)) + StatusPill(displayNameForStatus(transfer.status), tone = transfer.status.pillTone()) + } + Text( + "${formatBytes(transfer.totalSize)} · ${accessPolicyLabel(transfer.accessPolicy)}", + color = colors.foregroundLighter, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.width(8.dp)) + Icon(SendIcons.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(18.dp)) + } + } +} + +@Composable +internal fun FileArtwork(thumbnailBytes: ByteArray?, modifier: Modifier = Modifier) { + val bitmap = remember(thumbnailBytes) { thumbnailBytes?.let { runCatching { it.decodeToImageBitmap() }.getOrNull() } } + if (bitmap != null) { + androidx.compose.foundation.Image( + bitmap = bitmap, + contentDescription = null, + modifier = modifier.clip(RoundedCornerShape(10.dp)), + contentScale = ContentScale.Crop, + ) + } else { + Box(modifier, contentAlignment = Alignment.Center) { + Icon(SendIcons.File, contentDescription = null, tint = LocalVniDropColors.current.foregroundLight, modifier = Modifier.size(22.dp)) + } + } +} + +private fun TransferStatus.pillTone(): PillTone = when (this) { + TransferStatus.Sharing, TransferStatus.Done -> PillTone.Brand + TransferStatus.Importing, TransferStatus.Receiving -> PillTone.Warning + TransferStatus.Failed, TransferStatus.Cancelled -> PillTone.Destructive + TransferStatus.Stopped -> PillTone.Neutral +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendIcons.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendIcons.kt new file mode 100644 index 0000000..89089a3 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendIcons.kt @@ -0,0 +1,82 @@ +package com.vnidrop.app.feature.send + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathBuilder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +internal object SendIcons { + val Plus = lineIcon("Plus") { + moveTo(12f, 5f) + lineTo(12f, 19f) + moveTo(5f, 12f) + lineTo(19f, 12f) + } + val File = lineIcon("File") { + moveTo(14f, 2f) + lineTo(6f, 2f) + lineTo(6f, 22f) + lineTo(18f, 22f) + lineTo(18f, 6f) + close() + moveTo(14f, 2f) + lineTo(14f, 6f) + lineTo(18f, 6f) + } + val Back = lineIcon("Back") { + moveTo(19f, 12f) + lineTo(5f, 12f) + moveTo(12f, 19f) + lineTo(5f, 12f) + lineTo(12f, 5f) + } + val Delete = lineIcon("Delete") { + moveTo(4f, 7f); lineTo(20f, 7f) + moveTo(9f, 7f); lineTo(9f, 4f); lineTo(15f, 4f); lineTo(15f, 7f) + moveTo(6f, 7f); lineTo(7f, 21f); lineTo(17f, 21f); lineTo(18f, 7f) + moveTo(10f, 11f); lineTo(10f, 17f) + moveTo(14f, 11f); lineTo(14f, 17f) + } + val ChevronRight = lineIcon("ChevronRight") { + moveTo(9f, 18f) + lineTo(15f, 12f) + lineTo(9f, 6f) + } + val Shield = lineIcon("Shield") { + moveTo(12f, 2f) + lineTo(20f, 6f) + lineTo(20f, 12f) + arcTo(9f, 9f, 0f, false, true, 12f, 22f) + arcTo(9f, 9f, 0f, false, true, 4f, 12f) + lineTo(4f, 6f) + close() + } + val Globe = lineIcon("Globe") { + moveTo(21f, 12f) + arcTo(9f, 9f, 0f, true, true, 3f, 12f) + arcTo(9f, 9f, 0f, true, true, 21f, 12f) + moveTo(3f, 12f) + lineTo(21f, 12f) + moveTo(12f, 3f) + arcTo(14f, 14f, 0f, false, true, 12f, 21f) + arcTo(14f, 14f, 0f, false, true, 12f, 3f) + } +} + +private fun lineIcon(name: String, block: PathBuilder.() -> Unit): ImageVector = + ImageVector.Builder(name, 24.dp, 24.dp, 24f, 24f).apply { + path( + fill = SolidColor(Color.Transparent), + stroke = SolidColor(Color.Black), + strokeLineWidth = 2f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + pathFillType = PathFillType.NonZero, + pathBuilder = block, + ) + }.build() diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt new file mode 100644 index 0000000..2312276 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt @@ -0,0 +1,57 @@ +package com.vnidrop.app.feature.send + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vnidrop.app.core.rememberShareFilePicker +import com.vnidrop.app.ui.state.WindowClass + +@Composable +fun SendRoute( + viewModel: SendViewModel, + windowClass: WindowClass, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val coreState by viewModel.coreState.collectAsStateWithLifecycle() + val clipboard = LocalClipboardManager.current + val picker = rememberShareFilePicker(viewModel::onFilePicked, viewModel::onFilePickFailed) + val shareActions = rememberTransferShareActions() + + LaunchedEffect(viewModel) { + viewModel.effectFlow.collect { effect -> + when (effect) { + SendEffect.OpenFilePicker -> picker.pickFile() + is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket)) + } + } + } + + SendScreen( + coreState = coreState, + state = state, + windowClass = windowClass, + shareActions = shareActions, + onOpenComposer = viewModel::openComposer, + onDismissComposer = viewModel::dismissComposer, + onSelectFile = viewModel::selectFile, + onClearFile = viewModel::clearSelectedSource, + onTransferNameChanged = viewModel::setTransferName, + onSenderNameChanged = viewModel::setSenderName, + onAccessPolicyChanged = viewModel::setAccessPolicy, + onCreateShare = viewModel::createShare, + onTransferSelected = viewModel::openTransfer, + onCloseTransferDetails = viewModel::closeTransferDetails, + onCopyTicket = viewModel::copyTicket, + onActivity = viewModel::openActivity, + onReceivers = viewModel::openReceivers, + onShare = viewModel::openShare, + onCloseDetailPanel = viewModel::closeDetailPanel, + onInvitationResult = viewModel::onInvitationResult, + onRequestDelete = viewModel::requestDeleteTransfer, + onDismissDelete = viewModel::dismissDeleteTransfer, + onConfirmDelete = viewModel::confirmDeleteTransfer, + ) +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt new file mode 100644 index 0000000..e13347f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt @@ -0,0 +1,115 @@ +package com.vnidrop.app.feature.send + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import com.vnidrop.app.core.CoreState +import com.vnidrop.app.core.ShareAccessPolicy +import com.vnidrop.app.core.TransferDirection +import com.vnidrop.app.ui.components.AdaptiveDrawer +import com.vnidrop.app.ui.state.WindowClass + +@Composable +fun SendScreen( + coreState: CoreState, + state: SendState, + windowClass: WindowClass, + shareActions: TransferShareActions = UnavailableTransferShareActions, + onOpenComposer: () -> Unit, + onDismissComposer: () -> Unit, + onSelectFile: () -> Unit, + onClearFile: () -> Unit, + onTransferNameChanged: (String) -> Unit, + onSenderNameChanged: (String) -> Unit, + onAccessPolicyChanged: (ShareAccessPolicy) -> Unit, + onCreateShare: () -> Unit, + onTransferSelected: (ULong) -> Unit, + onCloseTransferDetails: () -> Unit, + onCopyTicket: (String) -> Unit, + onActivity: () -> Unit = {}, + onReceivers: () -> Unit = {}, + onShare: () -> Unit = {}, + onCloseDetailPanel: () -> Unit = {}, + onInvitationResult: (InvitationAction, Result) -> Unit = { _, _ -> }, + onRequestDelete: () -> Unit = {}, + onDismissDelete: () -> Unit = {}, + onConfirmDelete: () -> Unit = {}, +) { + val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send } + val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } } + val qrCache = remember { mutableStateMapOf() } + LaunchedEffect(outgoingTransfers.mapNotNull { it.ticket }) { + qrCache.keys.retainAll(outgoingTransfers.mapNotNull { it.ticket }.toSet()) + } + + Box(Modifier.fillMaxSize()) { + if (selectedTransfer != null) { + TransferDetails( + transfer = selectedTransfer, + events = coreState.events, + completedReceivers = state.receiverHistory.count { it.status == com.vnidrop.app.core.ReceiverDeliveryStatus.Completed }, + onBack = onCloseTransferDetails, + onActivity = onActivity, + onReceivers = onReceivers, + onShare = onShare, + onDelete = onRequestDelete, + ) + } else { + TransferCatalog( + transfers = outgoingTransfers, + transferThumbnails = state.transferThumbnails, + windowClass = windowClass, + onOpenComposer = onOpenComposer, + onTransferSelected = onTransferSelected, + ) + } + } + + if (state.isComposerOpen) { + AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismissComposer) { + TransferComposer( + coreInitialized = coreState.isInitialized, + state = state, + windowClass = windowClass, + onSelectFile = onSelectFile, + onClearFile = onClearFile, + onTransferNameChanged = onTransferNameChanged, + onSenderNameChanged = onSenderNameChanged, + onAccessPolicyChanged = onAccessPolicyChanged, + onCreateShare = onCreateShare, + ) + } + } + + if (selectedTransfer != null && state.detailPanel != null) { + AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onCloseDetailPanel) { + when (state.detailPanel) { + TransferDetailPanel.Activity -> TransferActivityPanel(coreState.events, selectedTransfer.transferId) + TransferDetailPanel.Receivers -> ReceiverHistoryPanel(state.receiverHistory, state.isLoadingReceivers) + TransferDetailPanel.Share -> TransferSharePanel( + selectedTransfer, + shareActions, + qrBitmap = selectedTransfer.ticket?.let(qrCache::get), + onQrRendered = { ticket, bitmap -> qrCache[ticket] = bitmap }, + onResult = onInvitationResult, + ) + } + } + } + + if (selectedTransfer != null && state.isDeleteConfirmationOpen) { + AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismissDelete) { + DeleteTransferPanel( + transferName = selectedTransfer.transferName, + isDeleting = state.isDeleting, + onCancel = onDismissDelete, + onConfirm = onConfirmDelete, + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt new file mode 100644 index 0000000..3f2e824 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt @@ -0,0 +1,264 @@ +package com.vnidrop.app.feature.send + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vnidrop.app.core.CoreGateway +import com.vnidrop.app.core.CoreSignal +import com.vnidrop.app.core.FileSystemService +import com.vnidrop.app.core.PickedShareFile +import com.vnidrop.app.core.ShareAccessPolicy +import com.vnidrop.app.core.ReceiverRequestModel +import com.vnidrop.app.preferences.PreferencesRepository +import com.vnidrop.app.ui.feedback.UiMessage +import com.vnidrop.app.ui.feedback.UiMessageController +import com.vnidrop.app.ui.feedback.UiMessageTone +import com.vnidrop.app.ui.feedback.UiText +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.send_transfer_created +import vnidrop.shared.generated.resources.transfer_nfc_written +import vnidrop.shared.generated.resources.transfer_deleted + +data class SendState( + val isComposerOpen: Boolean = false, + val selectedFile: PickedShareFile? = null, + val transferName: String = "", + val senderName: String = "", + val accessPolicy: ShareAccessPolicy = ShareAccessPolicy.RequireApproval, + val isSharing: Boolean = false, + val selectedTransferId: ULong? = null, + val transferThumbnails: Map = emptyMap(), + val detailPanel: TransferDetailPanel? = null, + val receiverHistory: List = emptyList(), + val isLoadingReceivers: Boolean = false, + val isDeleteConfirmationOpen: Boolean = false, + val isDeleting: Boolean = false, +) { + fun canCreateShare(coreInitialized: Boolean): Boolean = + coreInitialized && selectedFile != null && transferName.isNotBlank() && !isSharing +} + +enum class TransferDetailPanel { Activity, Receivers, Share } + +sealed interface SendEffect { + data object OpenFilePicker : SendEffect + data class CopyTicket(val ticket: String) : SendEffect +} + +class SendViewModel( + private val repository: CoreGateway, + private val fileSystemService: FileSystemService, + preferencesRepository: PreferencesRepository, + private val filePreviewRepository: FilePreviewRepository, + private val messages: UiMessageController, +) : ViewModel() { + private val _state = MutableStateFlow(SendState()) + val state: StateFlow = _state.asStateFlow() + val coreState = repository.state + + private val effects = Channel(Channel.BUFFERED) + val effectFlow = effects.receiveAsFlow() + + init { + viewModelScope.launch { + repository.signals.collect { signal -> + val transferId = when (signal) { + is CoreSignal.ReceiverHistoryChanged -> signal.transferId + is CoreSignal.ApprovalChanged -> signal.transferId + } + if (transferId == _state.value.selectedTransferId && + _state.value.detailPanel == TransferDetailPanel.Receivers + ) refreshReceivers(transferId) + } + } + viewModelScope.launch { + filePreviewRepository.previews.collect { previews -> + _state.update { it.copy(transferThumbnails = previews) } + } + } + viewModelScope.launch { + coreState.map { core -> + core.takeIf { it.isInitialized }?.transfers?.map { it.transferId }?.toSet() + }.distinctUntilChanged().collect { activeIds -> + if (activeIds != null) filePreviewRepository.restore(activeIds) + } + } + viewModelScope.launch { + preferencesRepository.preferences.collect { preferences -> + _state.update { current -> + if (current.senderName.isBlank()) current.copy(senderName = preferences.username) else current + } + } + } + } + + fun openComposer() { + if (_state.value.isSharing) return + _state.update { + it.copy( + isComposerOpen = true, + selectedFile = null, + transferName = "", + accessPolicy = ShareAccessPolicy.RequireApproval, + ) + } + } + + fun dismissComposer() { + if (_state.value.isSharing) return + _state.update { + it.copy( + isComposerOpen = false, + selectedFile = null, + transferName = "", + accessPolicy = ShareAccessPolicy.RequireApproval, + ) + } + } + + fun selectFile() = sendEffect(SendEffect.OpenFilePicker) + + fun onFilePicked(file: PickedShareFile) { + _state.update { + it.copy( + isComposerOpen = true, + selectedFile = file, + transferName = file.displayName, + ) + } + } + + fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason)) + + fun clearSelectedSource() { + _state.update { it.copy(selectedFile = null, transferName = "") } + } + + fun setTransferName(value: String) = _state.update { it.copy(transferName = value) } + fun setSenderName(value: String) = _state.update { it.copy(senderName = value) } + fun setAccessPolicy(value: ShareAccessPolicy) = _state.update { it.copy(accessPolicy = value) } + fun openTransfer(transferId: ULong) { + _state.update { it.copy(selectedTransferId = transferId, detailPanel = null) } + refreshReceivers(transferId) + } + fun closeTransferDetails() = _state.update { + it.copy( + selectedTransferId = null, + detailPanel = null, + receiverHistory = emptyList(), + isDeleteConfirmationOpen = false, + ) + } + fun openActivity() = _state.update { it.copy(detailPanel = TransferDetailPanel.Activity) } + fun openShare() = _state.update { it.copy(detailPanel = TransferDetailPanel.Share) } + fun openReceivers() { + val transferId = _state.value.selectedTransferId ?: return + _state.update { it.copy(detailPanel = TransferDetailPanel.Receivers) } + refreshReceivers(transferId) + } + fun closeDetailPanel() = _state.update { it.copy(detailPanel = null) } + fun requestDeleteTransfer() = _state.update { it.copy(isDeleteConfirmationOpen = true) } + fun dismissDeleteTransfer() { + if (!_state.value.isDeleting) _state.update { it.copy(isDeleteConfirmationOpen = false) } + } + fun confirmDeleteTransfer() { + val transferId = _state.value.selectedTransferId ?: return + if (_state.value.isDeleting) return + viewModelScope.launch { + _state.update { it.copy(isDeleting = true) } + repository.delete(transferId).fold( + onSuccess = { + filePreviewRepository.remove(transferId) + _state.update { + it.copy( + selectedTransferId = null, + detailPanel = null, + receiverHistory = emptyList(), + isDeleteConfirmationOpen = false, + isDeleting = false, + ) + } + messages.tryShow(UiMessage(UiText.Resource(Res.string.transfer_deleted), UiMessageTone.Success)) + }, + onFailure = { error -> + _state.update { it.copy(isDeleting = false) } + messages.error(error) + }, + ) + } + } + fun copyTicket(ticket: String) = sendEffect(SendEffect.CopyTicket(ticket)) + fun onInvitationResult(action: InvitationAction, result: Result) { + result.fold( + onSuccess = { + val message = when (action) { + InvitationAction.Export -> null + InvitationAction.Nfc -> Res.string.transfer_nfc_written + InvitationAction.Share -> null + } + message?.let { messages.tryShow(UiMessage(UiText.Resource(it), UiMessageTone.Success)) } + }, + onFailure = messages::error, + ) + } + + fun createShare() { + val current = state.value + val file = current.selectedFile ?: return + if (!current.canCreateShare(coreState.value.isInitialized)) return + viewModelScope.launch { + _state.update { it.copy(isSharing = true) } + val result = fileSystemService.sharePickedFile( + repository = repository, + file = file, + transferName = current.transferName.trim(), + senderName = current.senderName.trim(), + accessPolicy = current.accessPolicy, + ) + result.fold( + onSuccess = { share -> + file.thumbnailBytes?.let { filePreviewRepository.save(share.transferId, it) } + _state.update { + it.copy( + isComposerOpen = false, + selectedFile = null, + transferName = "", + accessPolicy = ShareAccessPolicy.RequireApproval, + isSharing = false, + ) + } + messages.show(UiMessage(UiText.Resource(Res.string.send_transfer_created), UiMessageTone.Success)) + }, + onFailure = { error -> + _state.update { it.copy(isSharing = false) } + messages.error(error) + }, + ) + } + } + + private fun sendEffect(effect: SendEffect) { + viewModelScope.launch { effects.send(effect) } + } + + private fun refreshReceivers(transferId: ULong) { + viewModelScope.launch { + _state.update { it.copy(isLoadingReceivers = true) } + repository.receiverRequests(transferId).fold( + onSuccess = { requests -> _state.update { it.copy(receiverHistory = requests, isLoadingReceivers = false) } }, + onFailure = { error -> + _state.update { it.copy(isLoadingReceivers = false) } + messages.error(error) + }, + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt new file mode 100644 index 0000000..3639126 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt @@ -0,0 +1,218 @@ +package com.vnidrop.app.feature.send + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vnidrop.app.core.PickedShareFile +import com.vnidrop.app.core.ShareAccessPolicy +import com.vnidrop.app.ui.components.Field +import com.vnidrop.app.ui.components.PrimaryButton +import com.vnidrop.app.ui.components.QuietButton +import com.vnidrop.app.ui.state.WindowClass +import com.vnidrop.app.ui.state.formatBytes +import com.vnidrop.app.ui.theme.LocalVniDropColors +import org.jetbrains.compose.resources.stringResource +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.button_change_file +import vnidrop.shared.generated.resources.button_choose_file +import vnidrop.shared.generated.resources.button_clear +import vnidrop.shared.generated.resources.button_share_file +import vnidrop.shared.generated.resources.button_sharing_file +import vnidrop.shared.generated.resources.field_sender_name +import vnidrop.shared.generated.resources.field_transfer_name +import vnidrop.shared.generated.resources.send_access_anyone +import vnidrop.shared.generated.resources.send_access_anyone_description +import vnidrop.shared.generated.resources.send_access_approval +import vnidrop.shared.generated.resources.send_access_approval_description +import vnidrop.shared.generated.resources.send_access_title +import vnidrop.shared.generated.resources.send_choose_file_body +import vnidrop.shared.generated.resources.send_choose_file_title +import vnidrop.shared.generated.resources.send_file_size_unknown +import vnidrop.shared.generated.resources.send_review_title + +@Composable +internal fun TransferComposer( + coreInitialized: Boolean, + state: SendState, + windowClass: WindowClass, + onSelectFile: () -> Unit, + onClearFile: () -> Unit, + onTransferNameChanged: (String) -> Unit, + onSenderNameChanged: (String) -> Unit, + onAccessPolicyChanged: (ShareAccessPolicy) -> Unit, + onCreateShare: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + val file = state.selectedFile + if (file == null) { + ChooseFileStep(onSelectFile) + } else { + ReviewFileStep( + file = file, + state = state, + windowClass = windowClass, + onSelectFile = onSelectFile, + onClearFile = onClearFile, + onTransferNameChanged = onTransferNameChanged, + onSenderNameChanged = onSenderNameChanged, + onAccessPolicyChanged = onAccessPolicyChanged, + onCreateShare = onCreateShare, + coreInitialized = coreInitialized, + ) + } + } +} + +@Composable +private fun ChooseFileStep(onSelectFile: () -> Unit) { + Text(stringResource(Res.string.send_choose_file_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Text( + stringResource(Res.string.send_choose_file_body), + color = LocalVniDropColors.current.foregroundLighter, + style = MaterialTheme.typography.bodyMedium, + ) + Surface(shape = RoundedCornerShape(16.dp), color = LocalVniDropColors.current.backgroundSurface200) { + Column( + modifier = Modifier.fillMaxWidth().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Icon(SendIcons.File, contentDescription = null, tint = LocalVniDropColors.current.brandLink, modifier = Modifier.size(32.dp)) + PrimaryButton(stringResource(Res.string.button_choose_file), onClick = onSelectFile) + } + } +} + +@Composable +private fun ReviewFileStep( + file: PickedShareFile, + state: SendState, + windowClass: WindowClass, + onSelectFile: () -> Unit, + onClearFile: () -> Unit, + onTransferNameChanged: (String) -> Unit, + onSenderNameChanged: (String) -> Unit, + onAccessPolicyChanged: (ShareAccessPolicy) -> Unit, + onCreateShare: () -> Unit, + coreInitialized: Boolean, +) { + Text(stringResource(Res.string.send_review_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + SelectedFileCard(file) + Field(state.transferName, onTransferNameChanged, stringResource(Res.string.field_transfer_name)) + Field(state.senderName, onSenderNameChanged, stringResource(Res.string.field_sender_name)) + Text(stringResource(Res.string.send_access_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + PolicyOption( + icon = SendIcons.Shield, + title = stringResource(Res.string.send_access_approval), + description = stringResource(Res.string.send_access_approval_description), + selected = state.accessPolicy == ShareAccessPolicy.RequireApproval, + onClick = { onAccessPolicyChanged(ShareAccessPolicy.RequireApproval) }, + ) + PolicyOption( + icon = SendIcons.Globe, + title = stringResource(Res.string.send_access_anyone), + description = stringResource(Res.string.send_access_anyone_description), + selected = state.accessPolicy == ShareAccessPolicy.AnyoneWithTransfer, + onClick = { onAccessPolicyChanged(ShareAccessPolicy.AnyoneWithTransfer) }, + ) + if (windowClass == WindowClass.Phone) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth()) + QuietButton(stringResource(Res.string.button_change_file), onClick = onSelectFile, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing) + } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ShareButton(state, coreInitialized, onCreateShare) + QuietButton(stringResource(Res.string.button_change_file), onClick = onSelectFile, enabled = !state.isSharing) + QuietButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = !state.isSharing) + } + } +} + +@Composable +private fun ShareButton(state: SendState, coreInitialized: Boolean, onCreateShare: () -> Unit, modifier: Modifier = Modifier) { + PrimaryButton( + if (state.isSharing) stringResource(Res.string.button_sharing_file) else stringResource(Res.string.button_share_file), + onClick = onCreateShare, + modifier = modifier, + enabled = state.canCreateShare(coreInitialized), + ) +} + +@Composable +private fun SelectedFileCard(file: PickedShareFile) { + Surface(shape = RoundedCornerShape(14.dp), color = LocalVniDropColors.current.backgroundSurface200) { + Row(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(44.dp).background(LocalVniDropColors.current.backgroundSurface300, RoundedCornerShape(11.dp))) { + FileArtwork(file.thumbnailBytes, Modifier.fillMaxSize()) + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(file.displayName, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + file.sizeBytes?.let(::formatBytes) ?: stringResource(Res.string.send_file_size_unknown), + color = LocalVniDropColors.current.foregroundLighter, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} + +@Composable +private fun PolicyOption( + icon: ImageVector, + title: String, + description: String, + selected: Boolean, + onClick: () -> Unit, +) { + val colors = LocalVniDropColors.current + val shape = RoundedCornerShape(14.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .background(if (selected) colors.backgroundSelection else colors.backgroundSurface200) + .selectable(selected = selected, role = Role.RadioButton, onClick = onClick) + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = if (selected) colors.brandLink else colors.foregroundLight, modifier = Modifier.size(22.dp)) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, fontWeight = FontWeight.SemiBold) + Text(description, color = colors.foregroundLighter, style = MaterialTheme.typography.bodySmall) + } + RadioButton(selected = selected, onClick = null) + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt new file mode 100644 index 0000000..61af8ad --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt @@ -0,0 +1,347 @@ +package com.vnidrop.app.feature.send + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +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.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +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.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.core.CoreEventModel +import com.vnidrop.app.core.ReceiverDeliveryStatus +import com.vnidrop.app.core.ReceiverRequestModel +import com.vnidrop.app.core.ShareAccessPolicy +import com.vnidrop.app.core.Transfer +import com.vnidrop.app.ui.components.AppCard +import com.vnidrop.app.ui.components.DestructiveButton +import com.vnidrop.app.ui.components.PrimaryButton +import com.vnidrop.app.ui.components.SecondaryButton +import com.vnidrop.app.ui.state.displayNameForStatus +import com.vnidrop.app.ui.state.formatBytes +import com.vnidrop.app.ui.theme.LocalVniDropColors +import org.jetbrains.compose.resources.decodeToImageBitmap +import org.jetbrains.compose.resources.stringResource +import qrcode.QRCode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import vnidrop.shared.generated.resources.* + +enum class InvitationAction { Export, Share, Nfc } + +@Composable +internal fun TransferDetails( + transfer: Transfer, + events: List, + completedReceivers: Int, + onBack: () -> Unit, + onActivity: () -> Unit, + onReceivers: () -> Unit, + onShare: () -> Unit, + onDelete: () -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize().statusBarsPadding(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { Icon(SendIcons.Back, stringResource(Res.string.button_back)) } + Text( + stringResource(Res.string.send_transfer_details_title), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + IconButton(onClick = onDelete) { + Icon(SendIcons.Delete, stringResource(Res.string.button_delete_transfer), tint = LocalVniDropColors.current.destructiveDefault) + } + } + } + item { + AppCard(title = transfer.transferName ?: stringResource(Res.string.send_new_transfer_title)) { + DetailValue(stringResource(Res.string.metadata_status), displayNameForStatus(transfer.status)) + HorizontalDivider(color = LocalVniDropColors.current.borderDefault) + DetailValue(stringResource(Res.string.metadata_size), formatBytes(transfer.totalSize)) + HorizontalDivider(color = LocalVniDropColors.current.borderDefault) + DetailValue(stringResource(Res.string.send_access_title), accessPolicyLabel(transfer.accessPolicy)) + } + } + item { + Surface(shape = RoundedCornerShape(16.dp), color = LocalVniDropColors.current.backgroundSurface200) { + Column { + DetailDestination( + title = stringResource(Res.string.transfer_activity_title), + description = stringResource(Res.string.transfer_activity_description), + count = events.count { it.transferId == transfer.transferId && it.isMeaningfulActivity() }, + onClick = onActivity, + ) + HorizontalDivider(color = LocalVniDropColors.current.borderDefault) + DetailDestination( + title = stringResource(Res.string.transfer_receivers_title), + description = stringResource(Res.string.transfer_receivers_description), + count = completedReceivers, + onClick = onReceivers, + ) + HorizontalDivider(color = LocalVniDropColors.current.borderDefault) + DetailDestination( + title = stringResource(Res.string.transfer_share_title), + description = stringResource(Res.string.transfer_share_description), + onClick = onShare, + ) + } + } + } + } +} + +@Composable +private fun DetailDestination(title: String, description: String, count: Int? = null, onClick: () -> Unit) { + Row( + Modifier.fillMaxWidth().clickable(onClick = onClick).padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, fontWeight = FontWeight.SemiBold) + Text(description, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall) + } + if (count != null && count > 0) { + Text(count.toString(), modifier = Modifier.background(LocalVniDropColors.current.backgroundSelection, RoundedCornerShape(20.dp)).padding(horizontal = 9.dp, vertical = 3.dp)) + Spacer(Modifier.width(8.dp)) + } + Icon(SendIcons.ChevronRight, null, tint = LocalVniDropColors.current.foregroundLighter, modifier = Modifier.size(18.dp)) + } +} + +@Composable +internal fun ReceiverHistoryPanel(receivers: List, loading: Boolean) { + PanelContainer(stringResource(Res.string.transfer_receivers_title)) { + when { + loading -> Box(Modifier.fillMaxWidth().padding(40.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + receivers.isEmpty() -> Text(stringResource(Res.string.transfer_no_receivers), color = LocalVniDropColors.current.foregroundLighter) + else -> receivers.forEachIndexed { index, receiver -> + if (index > 0) HorizontalDivider(color = LocalVniDropColors.current.borderDefault) + ReceiverRow(receiver) + } + } + } +} + +@Composable +private fun ReceiverRow(receiver: ReceiverRequestModel) { + val name = receiver.receiverName ?: receiver.receiverDeviceName ?: stringResource(Res.string.transfer_nearby_device) + Column(Modifier.fillMaxWidth().padding(vertical = 13.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(name, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + receiver.receiverDeviceName?.takeIf { it != name }?.let { + Text(it, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall) + } + Text(receiverStatusText(receiver.status), color = receiverStatusColor(receiver.status), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium) + } +} + +@Composable +internal fun TransferActivityPanel(events: List, transferId: ULong) { + val visible = events.filter { it.transferId == transferId && it.isMeaningfulActivity() }.sortedByDescending(CoreEventModel::timestamp) + PanelContainer(stringResource(Res.string.transfer_activity_title)) { + if (visible.isEmpty()) Text(stringResource(Res.string.transfer_no_activity), color = LocalVniDropColors.current.foregroundLighter) + else visible.forEachIndexed { index, event -> + if (index > 0) HorizontalDivider(color = LocalVniDropColors.current.borderDefault) + Text(eventTitle(event), modifier = Modifier.padding(vertical = 14.dp), fontWeight = FontWeight.Medium) + } + } +} + +@Composable +internal fun TransferSharePanel( + transfer: Transfer, + actions: TransferShareActions, + qrBitmap: androidx.compose.ui.graphics.ImageBitmap?, + onQrRendered: (String, androidx.compose.ui.graphics.ImageBitmap) -> Unit, + onResult: (InvitationAction, Result) -> Unit, +) { + DisposableEffect(actions) { onDispose(actions::cancelNfcWrite) } + val ticket = transfer.ticket + PanelContainer(stringResource(Res.string.transfer_share_title)) { + if (ticket == null) { + Text(stringResource(Res.string.transfer_event_preparing), color = LocalVniDropColors.current.foregroundLighter) + return@PanelContainer + } + val renderedBitmap by produceState(qrBitmap, ticket, qrBitmap) { + if (value == null) { + value = withContext(Dispatchers.Default) { + runCatching { QRCode.ofSquares().withSize(8).build(ticket).renderToBytes().decodeToImageBitmap() }.getOrNull() + } + value?.let { onQrRendered(ticket, it) } + } + } + val renderedQr = renderedBitmap + Surface( + modifier = Modifier.align(Alignment.CenterHorizontally).size(268.dp), + shape = RoundedCornerShape(18.dp), + color = Color.White, + ) { + if (renderedQr != null) { + Image(renderedQr, null, Modifier.padding(14.dp).fillMaxSize().clip(RoundedCornerShape(8.dp))) + } else { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + } + } + Text( + stringResource(Res.string.transfer_scan_qr), + modifier = Modifier.align(Alignment.CenterHorizontally), + color = LocalVniDropColors.current.foregroundLighter, + style = MaterialTheme.typography.bodySmall, + ) + if (actions.nfcAvailability != NfcShareAvailability.Hidden) { + var writingNfc by remember(ticket) { mutableStateOf(false) } + SecondaryButton( + if (writingNfc) stringResource(Res.string.transfer_nfc_waiting) else stringResource(Res.string.button_write_nfc), + onClick = { + writingNfc = true + actions.writeInvitationToNfc(ticket) { + writingNfc = false + onResult(InvitationAction.Nfc, it) + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = actions.nfcAvailability == NfcShareAvailability.Available && !writingNfc, + ) + if (actions.nfcAvailability == NfcShareAvailability.Unavailable) { + Text(stringResource(Res.string.transfer_nfc_unavailable), color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall) + } + } + SecondaryButton( + stringResource(Res.string.button_download_invitation), + onClick = { actions.exportInvitation(ticket, transfer.transferName.orEmpty()) { onResult(InvitationAction.Export, it) } }, + modifier = Modifier.fillMaxWidth(), + ) + PrimaryButton( + stringResource(Res.string.button_native_share), + onClick = { actions.shareInvitation(ticket, transfer.transferName.orEmpty()) { onResult(InvitationAction.Share, it) } }, + modifier = Modifier.fillMaxWidth(), + enabled = actions.canUseNativeShare, + ) + } +} + +@Composable +internal fun DeleteTransferPanel( + transferName: String?, + isDeleting: Boolean, + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + PanelContainer(stringResource(Res.string.transfer_delete_title)) { + Text( + stringResource(Res.string.transfer_delete_description, transferName ?: stringResource(Res.string.send_new_transfer_title)), + color = LocalVniDropColors.current.foregroundLighter, + style = MaterialTheme.typography.bodyMedium, + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)) { + SecondaryButton(stringResource(Res.string.button_cancel), onClick = onCancel, enabled = !isDeleting) + DestructiveButton( + if (isDeleting) stringResource(Res.string.transfer_deleting) else stringResource(Res.string.button_delete_transfer), + onClick = onConfirm, + enabled = !isDeleting, + ) + } + } +} + +@Composable +private fun PanelContainer(title: String, content: @Composable ColumnScope.() -> Unit) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + content() + } +} + +@Composable +private fun receiverStatusText(status: ReceiverDeliveryStatus) = stringResource(when (status) { + ReceiverDeliveryStatus.Requested -> Res.string.transfer_receiver_requested + ReceiverDeliveryStatus.Accepted -> Res.string.transfer_receiver_accepted + ReceiverDeliveryStatus.Refused -> Res.string.transfer_receiver_refused + ReceiverDeliveryStatus.Expired -> Res.string.transfer_receiver_expired + ReceiverDeliveryStatus.Completed -> Res.string.transfer_receiver_completed + ReceiverDeliveryStatus.Unknown -> Res.string.transfer_receiver_unknown +}) + +@Composable +private fun receiverStatusColor(status: ReceiverDeliveryStatus) = when (status) { + ReceiverDeliveryStatus.Completed -> LocalVniDropColors.current.brandDefault + ReceiverDeliveryStatus.Refused, ReceiverDeliveryStatus.Expired -> LocalVniDropColors.current.destructiveDefault + else -> LocalVniDropColors.current.foregroundLighter +} + +private fun CoreEventModel.isMeaningfulActivity() = + (phase == "import" && kind == "started") || + (phase == "ticket" && kind == "created") || + kind in setOf( + "receiver-requested", "receiver-accepted", "receiver-auto-approved", + "receiver-refused", "receiver-completed", "share-stopped", "failed", + ) + +@Composable +private fun eventTitle(event: CoreEventModel) = stringResource(when { + event.phase == "import" && event.kind == "started" -> Res.string.transfer_event_preparing + event.phase == "ticket" && event.kind == "created" -> Res.string.transfer_event_ready + event.kind == "receiver-requested" -> Res.string.transfer_event_requested + event.kind == "receiver-accepted" || event.kind == "receiver-auto-approved" -> Res.string.transfer_event_approved + event.kind == "receiver-refused" -> Res.string.transfer_event_refused + event.kind == "receiver-completed" -> Res.string.transfer_event_completed + event.kind == "share-stopped" -> Res.string.transfer_event_stopped + event.kind == "failed" -> Res.string.transfer_event_failed + else -> Res.string.transfer_event_updated +}) + +@Composable +private fun DetailValue(label: String, value: String) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall) + Text(value, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +internal fun accessPolicyLabel(policy: ShareAccessPolicy): String = when (policy) { + ShareAccessPolicy.RequireApproval -> stringResource(Res.string.send_access_approval) + ShareAccessPolicy.AnyoneWithTransfer -> stringResource(Res.string.send_access_anyone) +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.kt new file mode 100644 index 0000000..df22a95 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.kt @@ -0,0 +1,38 @@ +package com.vnidrop.app.feature.send + +import androidx.compose.runtime.Composable + +enum class NfcShareAvailability { Available, Unavailable, Hidden } + +interface TransferShareActions { + val canUseNativeShare: Boolean + val nfcAvailability: NfcShareAvailability + fun exportInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) + fun shareInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) + fun writeInvitationToNfc(ticket: String, onResult: (Result) -> Unit) + fun cancelNfcWrite() +} + +object UnavailableTransferShareActions : TransferShareActions { + override val canUseNativeShare = false + override val nfcAvailability = NfcShareAvailability.Hidden + override fun exportInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) = + onResult(Result.failure(UnsupportedOperationException("Invitation export is unavailable"))) + override fun shareInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) = + onResult(Result.failure(UnsupportedOperationException("System sharing is unavailable"))) + override fun writeInvitationToNfc(ticket: String, onResult: (Result) -> Unit) = + onResult(Result.failure(UnsupportedOperationException("NFC is unavailable"))) + override fun cancelNfcWrite() = Unit +} + +@Composable +expect fun rememberTransferShareActions(): TransferShareActions + +internal fun invitationFileName(transferName: String): String { + val safe = transferName.trim() + .map { character -> if (character.isLetterOrDigit() || character in "-_. ") character else '_' } + .joinToString("") + .trim('.', ' ') + .ifBlank { "VniDrop transfer" } + return if (safe.endsWith(".vnd", ignoreCase = true)) safe else "$safe.vnd" +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt new file mode 100644 index 0000000..ee7dabd --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt @@ -0,0 +1,55 @@ +package com.vnidrop.app.feature.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.about_bug_report +import vnidrop.shared.generated.resources.about_privacy +import vnidrop.shared.generated.resources.about_title +import vnidrop.shared.generated.resources.battery_level_title +import vnidrop.shared.generated.resources.device_model_title +import vnidrop.shared.generated.resources.device_name_title +import vnidrop.shared.generated.resources.network_title +import vnidrop.shared.generated.resources.os_version_title +import vnidrop.shared.generated.resources.value_unavailable +import vnidrop.shared.generated.resources.version_title + +@Composable +internal fun AboutSettings(state: SettingsState, onBack: () -> Unit, showBack: Boolean) { + val unavailable = stringResource(Res.string.value_unavailable) + val info = state.deviceInfo + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + SettingsTopBar(stringResource(Res.string.about_title), onBack, showBack) + SettingsGroup { + SettingsRow( + icon = SettingsIcons.Document, + title = stringResource(Res.string.about_privacy), + iconTone = SettingsIconTone.Neutral, + ) + SettingsDivider() + SettingsRow( + icon = SettingsIcons.Bug, + title = stringResource(Res.string.about_bug_report), + iconTone = SettingsIconTone.Neutral, + ) + } + SettingsGroup { + InfoItem(stringResource(Res.string.version_title), state.appVersion) + SettingsDivider(startPadding = 16.dp) + InfoItem(stringResource(Res.string.device_name_title), info?.deviceName.orUnavailable(unavailable)) + SettingsDivider(startPadding = 16.dp) + InfoItem(stringResource(Res.string.device_model_title), info?.deviceModel.orUnavailable(unavailable)) + SettingsDivider(startPadding = 16.dp) + InfoItem(stringResource(Res.string.os_version_title), info?.operatingSystem ?: unavailable) + SettingsDivider(startPadding = 16.dp) + InfoItem(stringResource(Res.string.network_title), info?.network.orUnavailable(unavailable)) + SettingsDivider(startPadding = 16.dp) + InfoItem(stringResource(Res.string.battery_level_title), info?.batteryLevel.orUnavailable(unavailable)) + } + } +} + +private fun String?.orUnavailable(fallback: String): String = this?.takeIf(String::isNotBlank) ?: fallback diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AppearanceSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AppearanceSettings.kt new file mode 100644 index 0000000..9349518 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AppearanceSettings.kt @@ -0,0 +1,66 @@ +package com.vnidrop.app.feature.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.vnidrop.app.ui.theme.ThemeMode +import com.vnidrop.app.ui.theme.LocalVniDropColors +import org.jetbrains.compose.resources.stringResource +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.appearance_dark_mode +import vnidrop.shared.generated.resources.appearance_light_mode +import vnidrop.shared.generated.resources.appearance_system_mode +import vnidrop.shared.generated.resources.appearance_title + +@Composable +internal fun AppearanceSettings( + mode: ThemeMode, + onModeChanged: (ThemeMode) -> Unit, + onBack: () -> Unit, + showBack: Boolean, +) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + SettingsTopBar(stringResource(Res.string.appearance_title), onBack, showBack) + SettingsGroup { + ThemeSettingsRow(SettingsIcons.Device, stringResource(Res.string.appearance_system_mode), mode == ThemeMode.System) { + onModeChanged(ThemeMode.System) + } + SettingsDivider() + ThemeSettingsRow(SettingsIcons.Moon, stringResource(Res.string.appearance_dark_mode), mode == ThemeMode.Dark) { + onModeChanged(ThemeMode.Dark) + } + SettingsDivider() + ThemeSettingsRow(SettingsIcons.Sun, stringResource(Res.string.appearance_light_mode), mode == ThemeMode.Light) { + onModeChanged(ThemeMode.Light) + } + } + } +} + +@Composable +private fun ThemeSettingsRow(icon: ImageVector, title: String, selected: Boolean, onClick: () -> Unit) { + SettingsRow( + icon = icon, + title = title, + selected = selected, + onClick = onClick, + showsDisclosure = false, + trailing = if (selected) { + { + Icon( + SettingsIcons.Check, + contentDescription = null, + tint = LocalVniDropColors.current.brandLink, + modifier = Modifier.size(20.dp), + ) + } + } else { + null + }, + ) +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NotificationSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NotificationSettings.kt new file mode 100644 index 0000000..8916d91 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NotificationSettings.kt @@ -0,0 +1,65 @@ +package com.vnidrop.app.feature.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vnidrop.app.notifications.NotificationPermission +import com.vnidrop.app.ui.components.SecondaryButton +import com.vnidrop.app.ui.theme.LocalVniDropColors +import org.jetbrains.compose.resources.stringResource +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.button_open_settings +import vnidrop.shared.generated.resources.notifications_description +import vnidrop.shared.generated.resources.notifications_local_title +import vnidrop.shared.generated.resources.notifications_permission_denied +import vnidrop.shared.generated.resources.notifications_unsupported +import vnidrop.shared.generated.resources.notifications_title + +@Composable +internal fun NotificationSettings( + state: SettingsState, + onEnabledChanged: (Boolean) -> Unit, + onOpenSettings: () -> Unit, + onBack: () -> Unit, + showBack: Boolean, +) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + SettingsTopBar(stringResource(Res.string.notifications_title), onBack, showBack) + SettingsGroup { + SettingsToggleRow( + icon = SettingsIcons.Bell, + title = stringResource(Res.string.notifications_local_title), + description = stringResource(Res.string.notifications_description), + checked = state.notificationsEnabled, + enabled = state.notificationPermission != NotificationPermission.Unsupported, + onCheckedChange = onEnabledChanged, + ) + } + when (state.notificationPermission) { + NotificationPermission.Denied -> NotificationPermissionHelp(onOpenSettings) + NotificationPermission.Unsupported -> NotificationSupportText(stringResource(Res.string.notifications_unsupported)) + else -> Unit + } + } +} + +@Composable +private fun NotificationPermissionHelp(onOpenSettings: () -> Unit) { + Column( + modifier = Modifier.padding(horizontal = 4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + NotificationSupportText(stringResource(Res.string.notifications_permission_denied)) + SecondaryButton(stringResource(Res.string.button_open_settings), onClick = onOpenSettings) + } +} + +@Composable +private fun NotificationSupportText(text: String) { + Text(text = text, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall) +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/PreferencesSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/PreferencesSettings.kt new file mode 100644 index 0000000..c7f720a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/PreferencesSettings.kt @@ -0,0 +1,66 @@ +package com.vnidrop.app.feature.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import com.vnidrop.app.core.FolderAccessStatus +import com.vnidrop.app.ui.components.Field +import com.vnidrop.app.ui.components.PrimaryButton +import com.vnidrop.app.ui.components.SecondaryButton +import org.jetbrains.compose.resources.stringResource +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.button_choose_folder +import vnidrop.shared.generated.resources.button_reset_default +import vnidrop.shared.generated.resources.field_username +import vnidrop.shared.generated.resources.folder_status_permission_required +import vnidrop.shared.generated.resources.folder_status_unavailable +import vnidrop.shared.generated.resources.folder_status_validating +import vnidrop.shared.generated.resources.folder_status_writable +import vnidrop.shared.generated.resources.preferences_receive_folder_title +import vnidrop.shared.generated.resources.preferences_title + +@Composable +internal fun PreferencesSettings( + state: SettingsState, + onUsernameChanged: (String) -> Unit, + onChooseFolder: () -> Unit, + onResetFolder: () -> Unit, + onBack: () -> Unit, + showBack: Boolean, +) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + SettingsTopBar(stringResource(Res.string.preferences_title), onBack, showBack) + Field(state.username, onUsernameChanged, stringResource(Res.string.field_username)) + SettingsGroup { + SettingsRow( + icon = SettingsIcons.Folder, + title = stringResource(Res.string.preferences_receive_folder_title), + value = state.receiveFolder?.displayName?.ifBlank { state.receiveFolder.value }, + iconTone = SettingsIconTone.Neutral, + ) + SettingsDivider() + SettingsRow( + icon = SettingsIcons.Check, + title = state.folderStatusLabel(), + iconTone = SettingsIconTone.Neutral, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + PrimaryButton(stringResource(Res.string.button_choose_folder), onClick = onChooseFolder) + SecondaryButton(stringResource(Res.string.button_reset_default), onClick = onResetFolder) + } + } +} + +@Composable +private fun SettingsState.folderStatusLabel(): String = if (isValidatingFolder) { + stringResource(Res.string.folder_status_validating) +} else { + when (folderAccessStatus) { + FolderAccessStatus.Writable -> stringResource(Res.string.folder_status_writable) + FolderAccessStatus.PermissionRequired -> stringResource(Res.string.folder_status_permission_required) + FolderAccessStatus.Unavailable -> stringResource(Res.string.folder_status_unavailable) + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsComponents.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsComponents.kt new file mode 100644 index 0000000..9b8b838 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsComponents.kt @@ -0,0 +1,216 @@ +package com.vnidrop.app.feature.settings + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.Spacer +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.layout.width +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.vnidrop.app.ui.theme.LocalVniDropColors +import org.jetbrains.compose.resources.stringResource +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.button_back + +internal enum class SettingsIconTone { Brand, Neutral } + +@Composable +internal fun SettingsTopBar(title: String, onBack: () -> Unit, showBack: Boolean) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (showBack) { + IconButton(onClick = onBack) { + Icon( + SettingsIcons.Back, + contentDescription = stringResource(Res.string.button_back), + tint = LocalVniDropColors.current.foregroundDefault, + ) + } + } + Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + } +} + +@Composable +internal fun SettingsGroup(content: @Composable ColumnScope.() -> Unit) { + val colors = LocalVniDropColors.current + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200), + border = BorderStroke(1.dp, colors.borderDefault.copy(alpha = 0.72f)), + ) { + Column(content = content) + } +} + +@Composable +internal fun SettingsRow( + icon: ImageVector, + title: String, + value: String? = null, + subtitle: String? = null, + selected: Boolean = false, + iconTone: SettingsIconTone = SettingsIconTone.Brand, + onClick: (() -> Unit)? = null, + showsDisclosure: Boolean = onClick != null, + trailing: @Composable (() -> Unit)? = null, +) { + val colors = LocalVniDropColors.current + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 64.dp) + .background(if (selected) colors.backgroundSelection else Color.Transparent) + .then(if (onClick == null) Modifier else Modifier.clickable(onClick = onClick)) + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SettingsLeadingIcon(icon, iconTone) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + subtitle?.let { + Text( + it, + color = colors.foregroundLighter, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + value?.let { + Text( + it, + modifier = Modifier.padding(start = 12.dp), + color = colors.foregroundLighter, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + when { + trailing != null -> { + Spacer(Modifier.width(10.dp)) + trailing() + } + onClick != null && showsDisclosure -> { + Spacer(Modifier.width(8.dp)) + Icon(SettingsIcons.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(18.dp)) + } + } + } +} + +@Composable +internal fun SettingsToggleRow( + icon: ImageVector, + title: String, + description: String, + checked: Boolean, + enabled: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + val colors = LocalVniDropColors.current + Row( + modifier = Modifier + .fillMaxWidth() + .alpha(if (enabled) 1f else 0.55f) + .toggleable( + value = checked, + enabled = enabled, + role = Role.Switch, + onValueChange = onCheckedChange, + ) + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SettingsLeadingIcon(icon, SettingsIconTone.Brand) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) + Text(description, color = colors.foregroundLighter, style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.width(16.dp)) + Switch( + checked = checked, + onCheckedChange = null, + enabled = enabled, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = colors.brandButton, + ), + ) + } +} + +@Composable +internal fun SettingsDivider(startPadding: Dp = 60.dp) { + HorizontalDivider( + modifier = Modifier.padding(start = startPadding), + color = LocalVniDropColors.current.borderDefault.copy(alpha = 0.72f), + ) +} + +@Composable +internal fun InfoItem(title: String, value: String) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text(title, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.labelLarge) + Text(value, style = MaterialTheme.typography.bodyLarge) + } +} + +@Composable +private fun SettingsLeadingIcon(icon: ImageVector, tone: SettingsIconTone) { + val colors = LocalVniDropColors.current + val foreground = if (tone == SettingsIconTone.Brand) colors.brandLink else colors.foregroundLight + val background = if (tone == SettingsIconTone.Brand) colors.brandLink.copy(alpha = 0.13f) else colors.backgroundSurface300 + Box( + modifier = Modifier.size(34.dp).background(background, RoundedCornerShape(10.dp)), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = null, tint = foreground, modifier = Modifier.size(19.dp)) + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsIcons.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsIcons.kt new file mode 100644 index 0000000..9b9c885 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsIcons.kt @@ -0,0 +1,143 @@ +package com.vnidrop.app.feature.settings + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathBuilder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +internal object SettingsIcons { + val ChevronRight = lineIcon("ChevronRight") { + moveTo(9f, 18f) + lineTo(15f, 12f) + lineTo(9f, 6f) + } + val Back = lineIcon("Back") { + moveTo(19f, 12f) + lineTo(5f, 12f) + moveTo(12f, 19f) + lineTo(5f, 12f) + lineTo(12f, 5f) + } + val Check = lineIcon("Check") { + moveTo(20f, 6f) + lineTo(9f, 17f) + lineTo(4f, 12f) + } + val Sun = lineIcon("Sun") { + moveTo(12f, 4f) + lineTo(12f, 2f) + moveTo(12f, 22f) + lineTo(12f, 20f) + moveTo(4.93f, 4.93f) + lineTo(6.34f, 6.34f) + moveTo(17.66f, 17.66f) + lineTo(19.07f, 19.07f) + moveTo(2f, 12f) + lineTo(4f, 12f) + moveTo(20f, 12f) + lineTo(22f, 12f) + moveTo(4.93f, 19.07f) + lineTo(6.34f, 17.66f) + moveTo(17.66f, 6.34f) + lineTo(19.07f, 4.93f) + moveTo(16f, 12f) + arcTo(4f, 4f, 0f, true, true, 8f, 12f) + arcTo(4f, 4f, 0f, true, true, 16f, 12f) + } + val Moon = lineIcon("Moon") { + moveTo(21f, 12.79f) + arcTo(9f, 9f, 0f, true, true, 11.21f, 3f) + arcTo(7f, 7f, 0f, false, false, 21f, 12.79f) + } + val Device = lineIcon("Device") { + roundRect(7f, 2f, 10f, 20f, 2.5f) + moveTo(11f, 18f) + lineTo(13f, 18f) + } + val Folder = lineIcon("Folder") { + moveTo(3f, 7f) + lineTo(9f, 7f) + lineTo(11f, 9f) + lineTo(21f, 9f) + lineTo(21f, 19f) + lineTo(3f, 19f) + close() + } + val Info = lineIcon("Info") { + moveTo(12f, 16f) + lineTo(12f, 12f) + moveTo(12f, 8f) + lineTo(12.01f, 8f) + moveTo(21f, 12f) + arcTo(9f, 9f, 0f, true, true, 3f, 12f) + arcTo(9f, 9f, 0f, true, true, 21f, 12f) + } + val Bell = lineIcon("Bell") { + moveTo(18f, 8f) + arcTo(6f, 6f, 0f, false, false, 6f, 8f) + lineTo(6f, 13f) + lineTo(4f, 17f) + lineTo(20f, 17f) + lineTo(18f, 13f) + close() + moveTo(10f, 21f) + arcTo(2f, 2f, 0f, false, false, 14f, 21f) + } + val Document = lineIcon("Document") { + moveTo(14f, 2f) + lineTo(6f, 2f) + arcTo(2f, 2f, 0f, false, false, 4f, 4f) + lineTo(4f, 20f) + arcTo(2f, 2f, 0f, false, false, 6f, 22f) + lineTo(18f, 22f) + arcTo(2f, 2f, 0f, false, false, 20f, 20f) + lineTo(20f, 8f) + lineTo(14f, 2f) + moveTo(14f, 2f) + lineTo(14f, 8f) + lineTo(20f, 8f) + } + val Bug = lineIcon("Bug") { + roundRect(7f, 6f, 10f, 14f, 5f) + moveTo(3f, 10f) + lineTo(7f, 10f) + moveTo(17f, 10f) + lineTo(21f, 10f) + moveTo(3f, 16f) + lineTo(7f, 16f) + moveTo(17f, 16f) + lineTo(21f, 16f) + moveTo(12f, 6f) + lineTo(12f, 20f) + } +} + +private fun lineIcon(name: String, block: PathBuilder.() -> Unit): ImageVector = + ImageVector.Builder(name, 24.dp, 24.dp, 24f, 24f).apply { + path( + fill = SolidColor(Color.Transparent), + stroke = SolidColor(Color.Black), + strokeLineWidth = 2f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + pathFillType = PathFillType.NonZero, + pathBuilder = block, + ) + }.build() + +private fun PathBuilder.roundRect(x: Float, y: Float, width: Float, height: Float, radius: Float) { + moveTo(x + radius, y) + lineTo(x + width - radius, y) + arcTo(radius, radius, 0f, false, true, x + width, y + radius) + lineTo(x + width, y + height - radius) + arcTo(radius, radius, 0f, false, true, x + width - radius, y + height) + lineTo(x + radius, y + height) + arcTo(radius, radius, 0f, false, true, x, y + height - radius) + lineTo(x, y + radius) + arcTo(radius, radius, 0f, false, true, x + radius, y) +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt new file mode 100644 index 0000000..583b7e8 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt @@ -0,0 +1,75 @@ +package com.vnidrop.app.feature.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vnidrop.app.ui.theme.ThemeMode +import org.jetbrains.compose.resources.stringResource +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.about_title +import vnidrop.shared.generated.resources.appearance_dark_mode +import vnidrop.shared.generated.resources.appearance_light_mode +import vnidrop.shared.generated.resources.appearance_system_mode +import vnidrop.shared.generated.resources.appearance_title +import vnidrop.shared.generated.resources.notifications_title +import vnidrop.shared.generated.resources.preferences_title +import vnidrop.shared.generated.resources.settings_title + +@Composable +internal fun SettingsOverview( + state: SettingsState, + onSectionSelected: (SettingsSection) -> Unit, + largeTitle: Boolean, +) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + stringResource(Res.string.settings_title), + style = if (largeTitle) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + SettingsGroup { + SettingsRow( + icon = SettingsIcons.Device, + title = stringResource(Res.string.preferences_title), + value = state.username, + selected = state.selectedSection == SettingsSection.Preferences, + onClick = { onSectionSelected(SettingsSection.Preferences) }, + ) + SettingsDivider() + SettingsRow( + icon = SettingsIcons.Sun, + title = stringResource(Res.string.appearance_title), + value = themeModeLabel(state.themeMode), + selected = state.selectedSection == SettingsSection.Appearance, + onClick = { onSectionSelected(SettingsSection.Appearance) }, + ) + } + SettingsGroup { + SettingsRow( + icon = SettingsIcons.Bell, + title = stringResource(Res.string.notifications_title), + selected = state.selectedSection == SettingsSection.Notifications, + onClick = { onSectionSelected(SettingsSection.Notifications) }, + ) + SettingsDivider() + SettingsRow( + icon = SettingsIcons.Info, + title = stringResource(Res.string.about_title), + selected = state.selectedSection == SettingsSection.About, + iconTone = SettingsIconTone.Neutral, + onClick = { onSectionSelected(SettingsSection.About) }, + ) + } + } +} + +@Composable +private fun themeModeLabel(mode: ThemeMode): String = when (mode) { + ThemeMode.System -> stringResource(Res.string.appearance_system_mode) + ThemeMode.Light -> stringResource(Res.string.appearance_light_mode) + ThemeMode.Dark -> stringResource(Res.string.appearance_dark_mode) +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt new file mode 100644 index 0000000..7701ed5 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt @@ -0,0 +1,32 @@ +package com.vnidrop.app.feature.settings + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vnidrop.app.core.rememberReceiveFolderPicker +import com.vnidrop.app.ui.state.WindowClass + +@Composable +fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) { + val state by viewModel.state.collectAsStateWithLifecycle() + val picker = rememberReceiveFolderPicker(viewModel::onReceiveFolderPicked, viewModel::onReceiveFolderPickFailed) + LaunchedEffect(viewModel) { + viewModel.effectFlow.collect { effect -> + when (effect) { + SettingsEffect.OpenReceiveFolderPicker -> picker.pickFolder() + } + } + } + SettingsScreen( + state = state, + windowClass = windowClass, + onSectionSelected = viewModel::selectSection, + onUsernameChanged = viewModel::setUsername, + onThemeModeChanged = viewModel::setThemeMode, + onChooseFolder = viewModel::chooseReceiveFolder, + onResetFolder = viewModel::resetReceiveFolder, + onNotificationsChanged = viewModel::setNotificationsEnabled, + onOpenNotificationSettings = viewModel::openNotificationSettings, + ) +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt new file mode 100644 index 0000000..faf0a3f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt @@ -0,0 +1,88 @@ +package com.vnidrop.app.feature.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vnidrop.app.ui.state.WindowClass +import com.vnidrop.app.ui.theme.ThemeMode + +@Composable +fun SettingsScreen( + state: SettingsState, + windowClass: WindowClass, + onSectionSelected: (SettingsSection) -> Unit, + onUsernameChanged: (String) -> Unit, + onThemeModeChanged: (ThemeMode) -> Unit, + onChooseFolder: () -> Unit, + onResetFolder: () -> Unit, + onNotificationsChanged: (Boolean) -> Unit, + onOpenNotificationSettings: () -> Unit, +) { + if (windowClass == WindowClass.Desktop) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(24.dp), + ) { + Column(Modifier.widthIn(min = 280.dp, max = 340.dp)) { + SettingsOverview(state, onSectionSelected, largeTitle = false) + } + Column(Modifier.weight(1f)) { + SettingsSectionContent( + state = state, + section = state.selectedSection.takeUnless { it == SettingsSection.Overview } ?: SettingsSection.Preferences, + onBack = {}, + showBack = false, + onUsernameChanged = onUsernameChanged, + onThemeModeChanged = onThemeModeChanged, + onChooseFolder = onChooseFolder, + onResetFolder = onResetFolder, + onNotificationsChanged = onNotificationsChanged, + onOpenNotificationSettings = onOpenNotificationSettings, + ) + } + } + } else { + when (state.selectedSection) { + SettingsSection.Overview -> SettingsOverview(state, onSectionSelected, largeTitle = true) + else -> SettingsSectionContent( + state = state, + section = state.selectedSection, + onBack = { onSectionSelected(SettingsSection.Overview) }, + showBack = true, + onUsernameChanged = onUsernameChanged, + onThemeModeChanged = onThemeModeChanged, + onChooseFolder = onChooseFolder, + onResetFolder = onResetFolder, + onNotificationsChanged = onNotificationsChanged, + onOpenNotificationSettings = onOpenNotificationSettings, + ) + } + } +} + +@Composable +private fun SettingsSectionContent( + state: SettingsState, + section: SettingsSection, + onBack: () -> Unit, + showBack: Boolean, + onUsernameChanged: (String) -> Unit, + onThemeModeChanged: (ThemeMode) -> Unit, + onChooseFolder: () -> Unit, + onResetFolder: () -> Unit, + onNotificationsChanged: (Boolean) -> Unit, + onOpenNotificationSettings: () -> Unit, +) { + when (section) { + SettingsSection.Overview -> Unit + SettingsSection.Preferences -> PreferencesSettings(state, onUsernameChanged, onChooseFolder, onResetFolder, onBack, showBack) + SettingsSection.Appearance -> AppearanceSettings(state.themeMode, onThemeModeChanged, onBack, showBack) + SettingsSection.Notifications -> NotificationSettings(state, onNotificationsChanged, onOpenNotificationSettings, onBack, showBack) + SettingsSection.About -> AboutSettings(state, onBack, showBack) + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt new file mode 100644 index 0000000..042473a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt @@ -0,0 +1,206 @@ +package com.vnidrop.app.feature.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vnidrop.app.DeviceInfo +import com.vnidrop.app.DeviceInfoProvider +import com.vnidrop.app.PlatformEnvironment +import com.vnidrop.app.core.FileSystemService +import com.vnidrop.app.core.FolderAccessStatus +import com.vnidrop.app.core.ReceiveFolder +import com.vnidrop.app.notifications.LocalNotificationService +import com.vnidrop.app.notifications.NotificationPermission +import com.vnidrop.app.preferences.PreferencesRepository +import com.vnidrop.app.ui.feedback.UiMessage +import com.vnidrop.app.ui.feedback.UiMessageController +import com.vnidrop.app.ui.feedback.UiMessageTone +import com.vnidrop.app.ui.feedback.UiText +import com.vnidrop.app.ui.theme.ThemeMode +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.button_open_settings +import vnidrop.shared.generated.resources.notifications_enabled_message +import vnidrop.shared.generated.resources.notifications_permission_denied +import vnidrop.shared.generated.resources.notifications_settings_open_failed +import vnidrop.shared.generated.resources.notifications_unsupported + +enum class SettingsSection { + Overview, + Preferences, + Appearance, + Notifications, + About, +} + +data class SettingsState( + val selectedSection: SettingsSection = SettingsSection.Overview, + val username: String = "", + val receiveFolder: ReceiveFolder? = null, + val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable, + val isValidatingFolder: Boolean = false, + val themeMode: ThemeMode = ThemeMode.System, + val notificationsEnabled: Boolean = false, + val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined, + val deviceInfo: DeviceInfo? = null, + val appVersion: String = "", + val isLoadingDeviceInfo: Boolean = false, +) + +sealed interface SettingsEffect { + data object OpenReceiveFolderPicker : SettingsEffect +} + +class SettingsViewModel( + private val environment: PlatformEnvironment, + private val deviceInfoProvider: DeviceInfoProvider, + private val fileSystemService: FileSystemService, + private val preferencesRepository: PreferencesRepository, + private val notifications: LocalNotificationService, + private val messages: UiMessageController, +) : ViewModel() { + private val _state = MutableStateFlow(SettingsState(appVersion = environment.appVersion)) + val state: StateFlow = _state.asStateFlow() + + private val effects = Channel(Channel.BUFFERED) + val effectFlow = effects.receiveAsFlow() + private var enableNotificationsAfterSettings = false + + init { + viewModelScope.launch { + preferencesRepository.preferences.collect { preferences -> + _state.update { + it.copy( + username = preferences.username, + receiveFolder = preferences.receiveFolder, + themeMode = preferences.themeMode, + notificationsEnabled = preferences.notificationsEnabled, + ) + } + validateFolder(preferences.receiveFolder) + } + } + refreshNotificationPermission() + loadDeviceInfo() + } + + fun selectSection(section: SettingsSection) { + _state.update { it.copy(selectedSection = section) } + if (section == SettingsSection.About) loadDeviceInfo() + } + + fun setUsername(value: String) { + viewModelScope.launch { preferencesRepository.setUsername(value) } + } + + fun setThemeMode(mode: ThemeMode) { + viewModelScope.launch { preferencesRepository.setThemeMode(mode) } + } + + fun chooseReceiveFolder() { + viewModelScope.launch { effects.send(SettingsEffect.OpenReceiveFolderPicker) } + } + + fun onReceiveFolderPicked(folder: ReceiveFolder) { + viewModelScope.launch { preferencesRepository.setReceiveFolder(folder) } + } + + fun onReceiveFolderPickFailed(reason: String) = messages.error(IllegalStateException(reason)) + + fun resetReceiveFolder() { + viewModelScope.launch { preferencesRepository.resetReceiveFolder() } + } + + fun setNotificationsEnabled(enabled: Boolean) { + viewModelScope.launch { + if (!enabled) { + preferencesRepository.setNotificationsEnabled(false) + notifications.cancelAll() + return@launch + } + + val permission = notifications.requestPermission() + _state.update { it.copy(notificationPermission = permission) } + if (permission == NotificationPermission.Granted) { + enableNotifications() + } else { + preferencesRepository.setNotificationsEnabled(false) + messages.show( + UiMessage( + UiText.Resource( + if (permission == NotificationPermission.Unsupported) Res.string.notifications_unsupported + else Res.string.notifications_permission_denied, + ), + UiMessageTone.Warning, + actionLabel = if (permission == NotificationPermission.Denied) { + UiText.Resource(Res.string.button_open_settings) + } else { + null + }, + onAction = if (permission == NotificationPermission.Denied) ::openNotificationSettings else null, + ), + ) + } + } + } + + fun openNotificationSettings() { + viewModelScope.launch { + enableNotificationsAfterSettings = true + notifications.openSettings().onFailure { + enableNotificationsAfterSettings = false + messages.show( + UiMessage(UiText.Resource(Res.string.notifications_settings_open_failed), UiMessageTone.Error), + ) + } + } + } + + fun refreshNotificationPermission() { + viewModelScope.launch { + val permission = notifications.refreshPermission() + _state.update { it.copy(notificationPermission = permission) } + if (enableNotificationsAfterSettings) { + enableNotificationsAfterSettings = false + if (permission == NotificationPermission.Granted) enableNotifications() + } else if (permission != NotificationPermission.Granted && _state.value.notificationsEnabled) { + preferencesRepository.setNotificationsEnabled(false) + notifications.cancelAll() + } + } + } + + private suspend fun enableNotifications() { + preferencesRepository.setNotificationsEnabled(true) + messages.show( + UiMessage(UiText.Resource(Res.string.notifications_enabled_message), UiMessageTone.Success), + ) + } + + private fun loadDeviceInfo() { + if (_state.value.isLoadingDeviceInfo) return + viewModelScope.launch { + _state.update { it.copy(isLoadingDeviceInfo = true) } + try { + val info = deviceInfoProvider.load() + _state.update { it.copy(deviceInfo = info, isLoadingDeviceInfo = false) } + } catch (error: Throwable) { + if (error is CancellationException) throw error + _state.update { it.copy(isLoadingDeviceInfo = false) } + messages.error(error, "Could not load device information.") + } + } + } + + private suspend fun validateFolder(folder: ReceiveFolder) { + _state.update { it.copy(isValidatingFolder = true) } + val status = fileSystemService.validateReceiveFolder(folder) + _state.update { it.copy(folderAccessStatus = status, isValidatingFolder = false) } + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.kt new file mode 100644 index 0000000..228e737 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.kt @@ -0,0 +1,27 @@ +package com.vnidrop.app.notifications + +import kotlinx.coroutines.flow.StateFlow + +enum class NotificationPermission { + NotDetermined, + Granted, + Denied, + Unsupported, +} + +data class LocalNotification( + val id: String, + val title: String, + val body: String, +) + +interface LocalNotificationService { + val permission: StateFlow + + suspend fun refreshPermission(): NotificationPermission + suspend fun requestPermission(): NotificationPermission + suspend fun openSettings(): Result + suspend fun publish(notification: LocalNotification): Result + suspend fun cancel(id: String) + suspend fun cancelAll() +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/platform/AppVisibility.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/platform/AppVisibility.kt new file mode 100644 index 0000000..cb71749 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/platform/AppVisibility.kt @@ -0,0 +1,14 @@ +package com.vnidrop.app.platform + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class AppVisibility(initiallyForeground: Boolean = true) { + private val _isForeground = MutableStateFlow(initiallyForeground) + val isForeground: StateFlow = _isForeground.asStateFlow() + + fun setForeground(value: Boolean) { + _isForeground.value = value + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt new file mode 100644 index 0000000..c9eaabd --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt @@ -0,0 +1,114 @@ +package com.vnidrop.app.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.booleanPreferencesKey +import com.vnidrop.app.core.ReceiveFolder +import com.vnidrop.app.core.ReceiveFolderKind +import com.vnidrop.app.ui.theme.ThemeMode +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import okio.Path.Companion.toPath + +data class AppPreferences( + val username: String, + val receiveFolder: ReceiveFolder, + val themeMode: ThemeMode, + val notificationsEnabled: Boolean, +) + +class AppPreferencesDefaults( + val username: String, + val receiveFolder: ReceiveFolder, + val themeMode: ThemeMode, + val notificationsEnabled: Boolean = false, +) + +interface PreferencesRepository { + val preferences: Flow + suspend fun setUsername(username: String) + suspend fun setReceiveFolder(folder: ReceiveFolder) + suspend fun resetReceiveFolder() + suspend fun setThemeMode(mode: ThemeMode) + suspend fun setNotificationsEnabled(enabled: Boolean) +} + +class AppPreferencesRepository( + private val dataStore: DataStore, + private val defaults: AppPreferencesDefaults, +) : PreferencesRepository { + override val preferences: Flow = dataStore.data + .catch { emit(emptyPreferences()) } + .map { prefs -> + AppPreferences( + username = prefs[PreferenceKeys.Username]?.takeIf { it.isNotBlank() } ?: defaults.username, + receiveFolder = ReceiveFolder( + kind = prefs[PreferenceKeys.ReceiveFolderKind]?.let { receiveFolderKindOrNull(it) } + ?: defaults.receiveFolder.kind, + value = prefs[PreferenceKeys.ReceiveFolderValue]?.takeIf { it.isNotBlank() } + ?: defaults.receiveFolder.value, + displayName = prefs[PreferenceKeys.ReceiveFolderDisplayName]?.takeIf { it.isNotBlank() } + ?: defaults.receiveFolder.displayName, + ), + themeMode = prefs[PreferenceKeys.ThemeMode]?.let { themeModeOrNull(it) } ?: defaults.themeMode, + notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled, + ) + } + + override suspend fun setUsername(username: String) { + dataStore.edit { prefs -> + prefs[PreferenceKeys.Username] = username.trim() + } + } + + override suspend fun setReceiveFolder(folder: ReceiveFolder) { + dataStore.edit { prefs -> + prefs[PreferenceKeys.ReceiveFolderKind] = folder.kind.name + prefs[PreferenceKeys.ReceiveFolderValue] = folder.value + prefs[PreferenceKeys.ReceiveFolderDisplayName] = folder.displayName + } + } + + override suspend fun resetReceiveFolder() { + setReceiveFolder(defaults.receiveFolder) + } + + override suspend fun setThemeMode(mode: ThemeMode) { + dataStore.edit { prefs -> + prefs[PreferenceKeys.ThemeMode] = mode.name + } + } + + override suspend fun setNotificationsEnabled(enabled: Boolean) { + dataStore.edit { prefs -> + prefs[PreferenceKeys.NotificationsEnabled] = enabled + } + } +} + +fun createAppPreferencesDataStore(appDataDir: String): DataStore = + PreferenceDataStoreFactory.createWithPath( + produceFile = { "$appDataDir/$AppPreferencesFileName".toPath() }, + ) + +private object PreferenceKeys { + val Username = stringPreferencesKey("username") + val ReceiveFolderKind = stringPreferencesKey("receive_folder_kind") + val ReceiveFolderValue = stringPreferencesKey("receive_folder_value") + val ReceiveFolderDisplayName = stringPreferencesKey("receive_folder_display_name") + val ThemeMode = stringPreferencesKey("theme_mode") + val NotificationsEnabled = booleanPreferencesKey("notifications_enabled") +} + +private fun receiveFolderKindOrNull(raw: String): ReceiveFolderKind? = + runCatching { ReceiveFolderKind.valueOf(raw) }.getOrNull() + +private fun themeModeOrNull(raw: String): ThemeMode? = + runCatching { ThemeMode.valueOf(raw) }.getOrNull() + +private const val AppPreferencesFileName = "app_preferences.preferences_pb" diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AdaptiveDrawer.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AdaptiveDrawer.kt new file mode 100644 index 0000000..4750994 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AdaptiveDrawer.kt @@ -0,0 +1,84 @@ +package com.vnidrop.app.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.vnidrop.app.ui.state.WindowClass +import com.vnidrop.app.ui.theme.LocalVniDropColors +import org.jetbrains.compose.resources.stringResource +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.button_close + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AdaptiveDrawer( + windowClass: WindowClass, + onDismissRequest: () -> Unit, + content: @Composable () -> Unit, +) { + if (windowClass == WindowClass.Phone) { + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = LocalVniDropColors.current.backgroundDialog, + ) { + ClosableModalContent(onDismissRequest, Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 12.dp), content) + } + } else { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = Modifier.fillMaxWidth(0.86f).widthIn(max = 560.dp), + shape = RoundedCornerShape(20.dp), + color = LocalVniDropColors.current.backgroundDialog, + shadowElevation = 12.dp, + ) { ClosableModalContent(onDismissRequest, content = content) } + } + } +} + +@Composable +private fun ClosableModalContent( + onClose: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Box(modifier) { + content() + IconButton( + onClick = onClose, + modifier = Modifier.align(androidx.compose.ui.Alignment.TopEnd).padding(8.dp).size(40.dp), + ) { + Icon(CloseIcon, stringResource(Res.string.button_close), tint = LocalVniDropColors.current.foregroundLight) + } + } +} + +private val CloseIcon = ImageVector.Builder("Close", 24.dp, 24.dp, 24f, 24f).apply { + path(fill = SolidColor(Color.Transparent), stroke = SolidColor(Color.Black), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round) { + moveTo(6f, 6f); lineTo(18f, 18f) + moveTo(18f, 6f); lineTo(6f, 18f) + } +}.build() diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AppCard.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AppCard.kt new file mode 100644 index 0000000..dec2df6 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AppCard.kt @@ -0,0 +1,44 @@ +package com.vnidrop.app.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +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(), + colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface75), + border = BorderStroke(1.dp, colors.borderDefault), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + trailing?.invoke() + } + HorizontalDivider(color = colors.borderDefault) + content() + } + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt new file mode 100644 index 0000000..730f9f9 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt @@ -0,0 +1,58 @@ +package com.vnidrop.app.ui.components + +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vnidrop.app.ui.theme.LocalVniDropColors + +@Composable +fun PrimaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) { + Button( + onClick = onClick, + enabled = enabled, + modifier = modifier.heightIn(min = 44.dp), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors(containerColor = LocalVniDropColors.current.brandButton, 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 DestructiveButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) { + Button( + onClick = onClick, + enabled = enabled, + modifier = modifier.heightIn(min = 44.dp), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = LocalVniDropColors.current.destructiveDefault, + contentColor = Color.White, + ), + ) { + Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Field.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Field.kt new file mode 100644 index 0000000..3cca324 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Field.kt @@ -0,0 +1,29 @@ +package com.vnidrop.app.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@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), + ) +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Status.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Status.kt new file mode 100644 index 0000000..1d352c8 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Status.kt @@ -0,0 +1,40 @@ +package com.vnidrop.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +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.MaterialTheme +import androidx.compose.material3.Text +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.unit.dp +import com.vnidrop.app.ui.theme.LocalVniDropColors + +enum class PillTone { Neutral, Success, Warning, Destructive, Brand } + +@Composable +fun StatusPill(label: String, modifier: Modifier = Modifier, tone: PillTone = PillTone.Neutral) { + val colors = LocalVniDropColors.current + val color = when (tone) { + PillTone.Neutral -> colors.foregroundLighter + PillTone.Success, PillTone.Brand -> colors.brandLink + PillTone.Warning -> colors.warningDefault + PillTone.Destructive -> colors.destructiveDefault + } + val shape = RoundedCornerShape(7.dp) + Row( + modifier = modifier.clip(shape).background(color.copy(alpha = 0.12f)) + .border(1.dp, color.copy(alpha = 0.32f), shape).padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(7.dp).clip(CircleShape).background(color)) + Text(label, modifier = Modifier.padding(start = 6.dp), color = color, style = MaterialTheme.typography.labelMedium, maxLines = 1) + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/TransferComponents.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/TransferComponents.kt new file mode 100644 index 0000000..6f04cdc --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/TransferComponents.kt @@ -0,0 +1,36 @@ +package com.vnidrop.app.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vnidrop.app.ui.theme.LocalVniDropColors + +@Composable +fun ProgressRow(label: String, progress: Float?, modifier: Modifier = Modifier) { + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(label, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (progress == null) LinearProgressIndicator(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(label, Modifier.weight(0.35f), color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall) + Text(value, Modifier.weight(0.65f), style = MaterialTheme.typography.bodySmall) + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/VniDropComponents.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/VniDropComponents.kt deleted file mode 100644 index 468a832..0000000 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/VniDropComponents.kt +++ /dev/null @@ -1,232 +0,0 @@ -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.backgroundSurface75), - border = BorderStroke(1.dp, colors.borderDefault), - ) { - 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.borderDefault) - 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.brandButton, 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.foregroundLighter - PillTone.Success -> colors.brandLink - PillTone.Warning -> colors.warningDefault - PillTone.Destructive -> colors.destructiveDefault - PillTone.Brand -> colors.brandLink - } - 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.destructive200), - border = BorderStroke(1.dp, colors.destructive400), - ) { - 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.foregroundLighter, - style = MaterialTheme.typography.bodySmall, - ) - Text( - text = value, - modifier = Modifier.weight(0.65f), - style = MaterialTheme.typography.bodySmall, - ) - } -} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt new file mode 100644 index 0000000..902f384 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt @@ -0,0 +1,54 @@ +package com.vnidrop.app.ui.feedback + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.receiveAsFlow +import org.jetbrains.compose.resources.StringResource + +sealed interface UiText { + data class Resource(val resource: StringResource) : UiText + data class Dynamic(val value: String) : UiText +} + +enum class UiMessageTone { + Info, + Success, + Warning, + Error, +} + +data class UiMessage( + val text: UiText, + val tone: UiMessageTone = UiMessageTone.Info, + val actionLabel: UiText? = null, + val onAction: (() -> Unit)? = null, +) + +class UiMessageController { + private val channel = Channel(Channel.BUFFERED) + val messages: Flow = channel.receiveAsFlow() + private val _dismissals = MutableSharedFlow(extraBufferCapacity = 1) + val dismissals: SharedFlow = _dismissals.asSharedFlow() + + suspend fun show(message: UiMessage) { + channel.send(message) + } + + fun tryShow(message: UiMessage): Boolean = channel.trySend(message).isSuccess + + fun dismissCurrent() { + _dismissals.tryEmit(Unit) + } + + fun error(error: Throwable, fallback: String = "Something went wrong.") { + tryShow( + UiMessage( + text = UiText.Dynamic(error.message?.takeIf(String::isNotBlank) ?: fallback), + tone = UiMessageTone.Error, + ), + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/VniDropSnackbarHost.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/VniDropSnackbarHost.kt new file mode 100644 index 0000000..d3454bb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/VniDropSnackbarHost.kt @@ -0,0 +1,161 @@ +package com.vnidrop.app.ui.feedback + +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarData +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import com.vnidrop.app.ui.theme.LocalVniDropColors +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.snackbar_dismiss + +@Composable +fun VniDropSnackbarHost( + controller: UiMessageController, + modifier: Modifier = Modifier, +) { + val hostState = remember { SnackbarHostState() } + var tone by remember { mutableStateOf(UiMessageTone.Info) } + LaunchedEffect(controller) { + controller.messages.collect { message -> + tone = message.tone + val result = hostState.showSnackbar( + message = message.text.resolve(), + actionLabel = message.actionLabel?.resolve(), + withDismissAction = true, + duration = if (message.tone == UiMessageTone.Error) SnackbarDuration.Long else SnackbarDuration.Short, + ) + if (result == SnackbarResult.ActionPerformed) message.onAction?.invoke() + } + } + LaunchedEffect(controller, hostState) { + controller.dismissals.collect { + hostState.currentSnackbarData?.dismiss() + } + } + + SnackbarHost(hostState = hostState, modifier = modifier) { data -> + val colors = LocalVniDropColors.current + val accent = when (tone) { + UiMessageTone.Info -> colors.brandLink + UiMessageTone.Success -> colors.brandDefault + UiMessageTone.Warning -> colors.warningDefault + UiMessageTone.Error -> colors.destructiveDefault + } + Surface( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp).widthIn(max = 520.dp).fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + color = colors.backgroundSurface200, + contentColor = colors.foregroundDefault, + shadowElevation = 6.dp, + ) { + SnackbarContent(data, accent) + } + } +} + +@Composable +private fun SnackbarContent(data: SnackbarData, actionColor: Color) { + BoxWithConstraints { + val actionLabel = data.visuals.actionLabel + if (actionLabel != null && maxWidth < 420.dp) { + Column(Modifier.fillMaxWidth().padding(start = 16.dp, end = 6.dp, top = 8.dp, bottom = 6.dp)) { + MessageAndDismiss(data) + TextButton(onClick = data::performAction, modifier = Modifier.align(Alignment.End)) { + Text(actionLabel, color = actionColor) + } + } + } else { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 6.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SnackbarMessage(data.visuals.message, Modifier.weight(1f)) + actionLabel?.let { label -> + TextButton(onClick = data::performAction) { Text(label, color = actionColor) } + } + DismissButton(data::dismiss) + } + } + } +} + +@Composable +private fun MessageAndDismiss(data: SnackbarData) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + SnackbarMessage(data.visuals.message, Modifier.weight(1f)) + DismissButton(data::dismiss) + } +} + +@Composable +private fun SnackbarMessage(message: String, modifier: Modifier = Modifier) { + Text( + text = message, + modifier = modifier.padding(vertical = 6.dp), + style = MaterialTheme.typography.bodyMedium, + ) +} + +@Composable +private fun DismissButton(onClick: () -> Unit) { + IconButton(onClick = onClick, modifier = Modifier.size(40.dp)) { + Icon( + imageVector = CloseIcon, + contentDescription = stringResource(Res.string.snackbar_dismiss), + tint = LocalVniDropColors.current.foregroundLighter, + modifier = Modifier.size(18.dp), + ) + } +} + +private val CloseIcon = ImageVector.Builder("Close", 24.dp, 24.dp, 24f, 24f).apply { + path( + fill = SolidColor(Color.Transparent), + stroke = SolidColor(Color.Black), + strokeLineWidth = 2f, + strokeLineCap = StrokeCap.Round, + pathFillType = PathFillType.NonZero, + ) { + moveTo(6f, 6f) + lineTo(18f, 18f) + moveTo(18f, 6f) + lineTo(6f, 18f) + } +}.build() + +private suspend fun UiText.resolve(): String = when (this) { + is UiText.Dynamic -> value + is UiText.Resource -> getString(resource) +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ReceiveScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ReceiveScreen.kt deleted file mode 100644 index eb2adef..0000000 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ReceiveScreen.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.vnidrop.app.ui.screens - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.runtime.Composable -import androidx.compose.ui.unit.dp -import com.vnidrop.app.VniDropAppEvent -import com.vnidrop.app.core.CoreUiState -import com.vnidrop.app.ui.components.AppCard -import com.vnidrop.app.ui.components.Field -import com.vnidrop.app.ui.components.PrimaryButton -import com.vnidrop.app.ui.components.SecondaryButton -import com.vnidrop.app.ui.state.ReceiveUiState -import org.jetbrains.compose.resources.stringResource -import vnidrop.shared.generated.resources.Res -import vnidrop.shared.generated.resources.button_inspect_ticket -import vnidrop.shared.generated.resources.button_receive -import vnidrop.shared.generated.resources.button_receiving -import vnidrop.shared.generated.resources.field_output_directory -import vnidrop.shared.generated.resources.field_receiver_name -import vnidrop.shared.generated.resources.field_ticket -import vnidrop.shared.generated.resources.receive_subtitle -import vnidrop.shared.generated.resources.receive_title -import vnidrop.shared.generated.resources.ticket_card_title - -@Composable -fun ReceiveScreen( - coreState: CoreUiState, - receiveState: ReceiveUiState, - onEvent: (VniDropAppEvent) -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { - ScreenHeader(stringResource(Res.string.receive_title), stringResource(Res.string.receive_subtitle)) - ErrorSection(coreState) - AppCard(title = stringResource(Res.string.ticket_card_title)) { - Field( - value = receiveState.ticket, - onValueChange = { onEvent(VniDropAppEvent.ReceiveTicketChanged(it)) }, - label = stringResource(Res.string.field_ticket), - minLines = 4, - ) - Field( - value = receiveState.outputDirectory, - onValueChange = { onEvent(VniDropAppEvent.OutputDirectoryChanged(it)) }, - label = stringResource(Res.string.field_output_directory), - ) - Field( - value = receiveState.receiverName, - onValueChange = { onEvent(VniDropAppEvent.ReceiverNameChanged(it)) }, - label = stringResource(Res.string.field_receiver_name), - ) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - SecondaryButton( - text = stringResource(Res.string.button_inspect_ticket), - onClick = { onEvent(VniDropAppEvent.InspectTicketClicked) }, - enabled = receiveState.canInspect(coreState.isInitialized), - ) - PrimaryButton( - text = if (receiveState.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive), - onClick = { onEvent(VniDropAppEvent.ReceiveClicked) }, - enabled = receiveState.canReceive(coreState.isInitialized), - ) - } - } - coreState.lastInspection?.let { TicketInspectionCard(it) } - ProgressSection(coreState) - } -} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ScreenSections.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ScreenSections.kt index ca11183..5749781 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ScreenSections.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ScreenSections.kt @@ -3,15 +3,10 @@ package com.vnidrop.app.ui.screens import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -19,35 +14,22 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.vnidrop.app.core.CoreUiState +import com.vnidrop.app.core.CoreState +import com.vnidrop.app.core.TicketInspectionModel import com.vnidrop.app.ui.components.AppCard -import com.vnidrop.app.ui.components.ErrorBanner 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.SecondaryButton -import com.vnidrop.app.ui.components.StatusPill -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.theme.LocalVniDropColors import org.jetbrains.compose.resources.stringResource -import uniffi.vnidrop.CoreEvent -import uniffi.vnidrop.ReceiverRequest -import uniffi.vnidrop.TicketInspection import vnidrop.shared.generated.resources.Res -import vnidrop.shared.generated.resources.button_approve -import vnidrop.shared.generated.resources.button_refuse -import vnidrop.shared.generated.resources.event_log_title import vnidrop.shared.generated.resources.metadata_files import vnidrop.shared.generated.resources.metadata_hash import vnidrop.shared.generated.resources.metadata_kind import vnidrop.shared.generated.resources.metadata_sender import vnidrop.shared.generated.resources.metadata_size import vnidrop.shared.generated.resources.metadata_transfer -import vnidrop.shared.generated.resources.no_events import vnidrop.shared.generated.resources.progress_title import vnidrop.shared.generated.resources.ticket_details_title import vnidrop.shared.generated.resources.ticket_no_metadata @@ -61,30 +43,23 @@ fun ScreenHeader(title: String, subtitle: String) { } } -@Composable -fun ErrorSection(coreState: CoreUiState) { - friendlyCoreError(coreState.error)?.let { ErrorBanner(it) } -} - @Composable fun EmptyText(text: String) { Text(text, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodyMedium) } @Composable -fun ProgressSection(coreState: CoreUiState) { +fun ProgressSection(coreState: CoreState) { val progress = summarizeProgress(coreState.events) if (progress.isNotEmpty()) { AppCard(title = stringResource(Res.string.progress_title)) { - progress.forEach { item -> - ProgressRow(label = item.label, progress = item.progress) - } + progress.forEach { item -> ProgressRow(label = item.label, progress = item.progress) } } } } @Composable -fun TicketInspectionCard(inspection: TicketInspection) { +fun TicketInspectionCard(inspection: TicketInspectionModel) { AppCard(title = stringResource(Res.string.ticket_details_title)) { MetadataRow(stringResource(Res.string.metadata_kind), inspection.kind) inspection.metadata?.let { metadata -> @@ -97,90 +72,14 @@ fun TicketInspectionCard(inspection: TicketInspection) { } } -@Composable -fun ReceiverRequestList( - requests: List, - onRespondRequest: (String, Boolean) -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - requests.forEach { request -> - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(LocalVniDropColors.current.backgroundSurface100) - .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.foregroundLighter, 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.foregroundLighter, style = MaterialTheme.typography.bodySmall) } - if (request.status == "requested") { - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - SecondaryButton(stringResource(Res.string.button_refuse), onClick = { onRespondRequest(request.id, false) }) - PrimaryButton(stringResource(Res.string.button_approve), onClick = { onRespondRequest(request.id, true) }) - } - } - } - } - } -} - @Composable fun TicketText(ticket: String) { SelectionContainer { Text( text = ticket, - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(LocalVniDropColors.current.backgroundSurface200) - .padding(12.dp), + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(LocalVniDropColors.current.backgroundSurface200).padding(12.dp), style = MaterialTheme.typography.bodySmall, ) } } - -@Composable -fun DiagnosticsPanel(events: List) { - AppCard(title = stringResource(Res.string.event_log_title)) { - if (events.isEmpty()) { - EmptyText(stringResource(Res.string.no_events)) - } 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.backgroundSurface100) - .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.foregroundLighter, style = MaterialTheme.typography.bodySmall) - } -} - -@Composable -fun SectionDivider() { - HorizontalDivider(color = LocalVniDropColors.current.borderDefault) -} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendScreen.kt deleted file mode 100644 index 9c41ccf..0000000 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendScreen.kt +++ /dev/null @@ -1,165 +0,0 @@ -package com.vnidrop.app.ui.screens - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.vnidrop.app.VniDropAppEvent -import com.vnidrop.app.core.CoreUiState -import com.vnidrop.app.ui.components.AppCard -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.SecondaryButton -import com.vnidrop.app.ui.components.StatusPill -import com.vnidrop.app.ui.state.SendUiState -import com.vnidrop.app.ui.state.formatBytes -import org.jetbrains.compose.resources.stringResource -import uniffi.vnidrop.ReceiverRequest -import uniffi.vnidrop.ShareResult -import vnidrop.shared.generated.resources.Res -import vnidrop.shared.generated.resources.button_approve -import vnidrop.shared.generated.resources.button_clear -import vnidrop.shared.generated.resources.button_copy -import vnidrop.shared.generated.resources.button_create_share_ticket -import vnidrop.shared.generated.resources.button_creating_ticket -import vnidrop.shared.generated.resources.button_refresh -import vnidrop.shared.generated.resources.button_refuse -import vnidrop.shared.generated.resources.button_select_file -import vnidrop.shared.generated.resources.button_use_locally -import vnidrop.shared.generated.resources.field_sender_name -import vnidrop.shared.generated.resources.field_transfer_name -import vnidrop.shared.generated.resources.metadata_name -import vnidrop.shared.generated.resources.metadata_size -import vnidrop.shared.generated.resources.metadata_source -import vnidrop.shared.generated.resources.metadata_transfer -import vnidrop.shared.generated.resources.receiver_requests_title -import vnidrop.shared.generated.resources.send_source_empty -import vnidrop.shared.generated.resources.send_subtitle -import vnidrop.shared.generated.resources.send_title -import vnidrop.shared.generated.resources.share_ticket_title -import vnidrop.shared.generated.resources.source_title -import vnidrop.shared.generated.resources.transfer_details_title - -@Composable -fun SendScreen( - coreState: CoreUiState, - sendState: SendUiState, - onEvent: (VniDropAppEvent) -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { - ScreenHeader(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle)) - ErrorSection(coreState) - SendSourceCard( - sendState = sendState, - onEvent = onEvent, - ) - SendDetailsCard( - coreState = coreState, - sendState = sendState, - onEvent = onEvent, - ) - coreState.lastShare?.let { share -> - ShareResultCard( - share = share, - requests = coreState.receiverRequests, - onEvent = onEvent, - ) - } - ProgressSection(coreState) - } -} - -@Composable -private fun SendSourceCard( - sendState: SendUiState, - onEvent: (VniDropAppEvent) -> Unit, -) { - AppCard(title = stringResource(Res.string.source_title)) { - if (sendState.selectedSource.isBlank()) { - EmptyText(stringResource(Res.string.send_source_empty)) - } else { - MetadataRow(stringResource(Res.string.metadata_name), sendState.selectedDisplayName.ifBlank { sendState.selectedSource.substringAfterLast('/') }) - MetadataRow(stringResource(Res.string.metadata_source), sendState.selectedSource) - } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - PrimaryButton( - text = stringResource(Res.string.button_select_file), - onClick = { onEvent(VniDropAppEvent.SelectFileClicked) }, - ) - SecondaryButton( - text = stringResource(Res.string.button_clear), - onClick = { onEvent(VniDropAppEvent.ClearSelectedSourceClicked) }, - enabled = sendState.hasSelectedSource, - ) - } - } -} - -@Composable -private fun SendDetailsCard( - coreState: CoreUiState, - sendState: SendUiState, - onEvent: (VniDropAppEvent) -> Unit, -) { - AppCard(title = stringResource(Res.string.transfer_details_title)) { - Field( - value = sendState.transferName, - onValueChange = { onEvent(VniDropAppEvent.TransferNameChanged(it)) }, - label = stringResource(Res.string.field_transfer_name), - ) - Field( - value = sendState.senderName, - onValueChange = { onEvent(VniDropAppEvent.SenderNameChanged(it)) }, - label = stringResource(Res.string.field_sender_name), - ) - PrimaryButton( - text = if (sendState.isSharing) stringResource(Res.string.button_creating_ticket) else stringResource(Res.string.button_create_share_ticket), - onClick = { onEvent(VniDropAppEvent.CreateShareClicked) }, - enabled = sendState.canCreateShare(coreState.isInitialized), - ) - } -} - -@Composable -private fun ShareResultCard( - share: ShareResult, - requests: List, - onEvent: (VniDropAppEvent) -> Unit, -) { - AppCard(title = stringResource(Res.string.share_ticket_title), trailing = { - StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand) - }) { - MetadataRow(stringResource(Res.string.metadata_transfer), share.transferName) - MetadataRow(stringResource(Res.string.metadata_size), formatBytes(share.totalSize)) - TicketText(share.ticket) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - PrimaryButton( - text = stringResource(Res.string.button_copy), - onClick = { onEvent(VniDropAppEvent.CopyTicketClicked(share.ticket)) }, - ) - SecondaryButton( - text = stringResource(Res.string.button_use_locally), - onClick = { onEvent(VniDropAppEvent.UseTicketLocallyClicked(share.ticket)) }, - ) - SecondaryButton( - text = stringResource(Res.string.button_refresh), - onClick = { onEvent(VniDropAppEvent.RefreshReceiverRequestsClicked(share.transferId)) }, - ) - } - if (requests.isNotEmpty()) { - SectionDivider() - Text(stringResource(Res.string.receiver_requests_title), fontWeight = FontWeight.SemiBold) - ReceiverRequestList( - requests = requests, - onRespondRequest = { requestId, accepted -> - onEvent(VniDropAppEvent.RespondReceiverRequestClicked(requestId, accepted)) - }, - ) - } - } -} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SettingsScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SettingsScreen.kt deleted file mode 100644 index 88c1b67..0000000 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SettingsScreen.kt +++ /dev/null @@ -1,614 +0,0 @@ -package com.vnidrop.app.ui.screens - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.PathFillType -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.vnidrop.app.DeviceInfo -import com.vnidrop.app.core.CoreUiState -import com.vnidrop.app.ui.state.WindowClass -import com.vnidrop.app.ui.theme.LocalVniDropColors -import com.vnidrop.app.ui.theme.ThemeMode -import org.jetbrains.compose.resources.stringResource -import vnidrop.shared.generated.resources.Res -import vnidrop.shared.generated.resources.about_bug_report -import vnidrop.shared.generated.resources.about_privacy -import vnidrop.shared.generated.resources.about_title -import vnidrop.shared.generated.resources.appearance_auto_description -import vnidrop.shared.generated.resources.appearance_dark_mode -import vnidrop.shared.generated.resources.appearance_light_mode -import vnidrop.shared.generated.resources.appearance_mode_title -import vnidrop.shared.generated.resources.appearance_system_mode -import vnidrop.shared.generated.resources.appearance_title -import vnidrop.shared.generated.resources.battery_level_title -import vnidrop.shared.generated.resources.core_status_ready -import vnidrop.shared.generated.resources.device_model_title -import vnidrop.shared.generated.resources.device_name_title -import vnidrop.shared.generated.resources.network_title -import vnidrop.shared.generated.resources.node_title -import vnidrop.shared.generated.resources.not_initialized -import vnidrop.shared.generated.resources.os_version_title -import vnidrop.shared.generated.resources.settings_title -import vnidrop.shared.generated.resources.value_unavailable -import vnidrop.shared.generated.resources.version_title - -private enum class SettingsPane { - Overview, - Appearance, - About, -} - -@Composable -fun SettingsScreen( - deviceInfo: DeviceInfo, - coreState: CoreUiState, - themeMode: ThemeMode, - windowClass: WindowClass, - onThemeModeChange: (ThemeMode) -> Unit, -) { - var pane by remember { mutableStateOf(SettingsPane.Overview) } - - when (windowClass) { - WindowClass.Desktop -> DesktopSettings( - selectedPane = pane, - onPaneSelected = { pane = it }, - deviceInfo = deviceInfo, - coreState = coreState, - themeMode = themeMode, - onThemeModeChange = onThemeModeChange, - ) - else -> MobileSettings( - pane = pane, - onPaneSelected = { pane = it }, - deviceInfo = deviceInfo, - coreState = coreState, - themeMode = themeMode, - onThemeModeChange = onThemeModeChange, - ) - } -} - -@Composable -private fun MobileSettings( - pane: SettingsPane, - onPaneSelected: (SettingsPane) -> Unit, - deviceInfo: DeviceInfo, - coreState: CoreUiState, - themeMode: ThemeMode, - onThemeModeChange: (ThemeMode) -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - ErrorSection(coreState) - when (pane) { - SettingsPane.Overview -> SettingsOverview( - coreState = coreState, - themeMode = themeMode, - onOpenAppearance = { onPaneSelected(SettingsPane.Appearance) }, - onOpenAbout = { onPaneSelected(SettingsPane.About) }, - largeTitle = true, - ) - SettingsPane.Appearance -> AppearanceSettings( - themeMode = themeMode, - onThemeModeChange = onThemeModeChange, - onBack = { onPaneSelected(SettingsPane.Overview) }, - showBack = true, - ) - SettingsPane.About -> AboutSettings( - deviceInfo = deviceInfo, - coreState = coreState, - onBack = { onPaneSelected(SettingsPane.Overview) }, - showBack = true, - ) - } - } -} - -@Composable -private fun DesktopSettings( - selectedPane: SettingsPane, - onPaneSelected: (SettingsPane) -> Unit, - deviceInfo: DeviceInfo, - coreState: CoreUiState, - themeMode: ThemeMode, - onThemeModeChange: (ThemeMode) -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(24.dp), - verticalAlignment = Alignment.Top, - ) { - Column( - modifier = Modifier.widthIn(min = 280.dp, max = 340.dp), - verticalArrangement = Arrangement.spacedBy(18.dp), - ) { - SettingsOverview( - coreState = coreState, - themeMode = themeMode, - onOpenAppearance = { onPaneSelected(SettingsPane.Appearance) }, - onOpenAbout = { onPaneSelected(SettingsPane.About) }, - largeTitle = false, - selectedPane = selectedPane, - ) - } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(18.dp), - ) { - when (selectedPane) { - SettingsPane.Overview, - SettingsPane.Appearance -> AppearanceSettings( - themeMode = themeMode, - onThemeModeChange = onThemeModeChange, - onBack = {}, - showBack = false, - ) - SettingsPane.About -> AboutSettings( - deviceInfo = deviceInfo, - coreState = coreState, - onBack = {}, - showBack = false, - ) - } - } - } -} - -@Composable -private fun SettingsOverview( - coreState: CoreUiState, - themeMode: ThemeMode, - onOpenAppearance: () -> Unit, - onOpenAbout: () -> Unit, - largeTitle: Boolean, - selectedPane: SettingsPane = SettingsPane.Overview, -) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - if (largeTitle) { - SettingsLargeTitle(stringResource(Res.string.settings_title)) - } else { - Text(stringResource(Res.string.settings_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) - } - SettingsGroup { - SettingsRow( - icon = SettingsIcons.Sun, - title = stringResource(Res.string.appearance_title), - value = themeMode.displayName(), - selected = selectedPane == SettingsPane.Appearance, - onClick = onOpenAppearance, - ) - } - SettingsGroup { - SettingsRow( - icon = SettingsIcons.Node, - title = stringResource(Res.string.node_title), - value = if (coreState.isInitialized) stringResource(Res.string.core_status_ready) else stringResource(Res.string.not_initialized), - iconTone = IconTone.Neutral, - ) - SettingsRow( - icon = SettingsIcons.Info, - title = stringResource(Res.string.about_title), - selected = selectedPane == SettingsPane.About, - onClick = onOpenAbout, - ) - } - } -} - -@Composable -private fun AppearanceSettings( - themeMode: ThemeMode, - onThemeModeChange: (ThemeMode) -> Unit, - onBack: () -> Unit, - showBack: Boolean, -) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - SettingsTopBar(title = stringResource(Res.string.appearance_title), onBack = onBack, showBack = showBack) - Text( - text = stringResource(Res.string.appearance_mode_title), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) - ThemeChoice( - icon = SettingsIcons.Device, - title = stringResource(Res.string.appearance_system_mode), - description = stringResource(Res.string.appearance_auto_description), - selected = themeMode == ThemeMode.System, - onClick = { onThemeModeChange(ThemeMode.System) }, - ) - ThemeChoice( - icon = SettingsIcons.Moon, - title = stringResource(Res.string.appearance_dark_mode), - selected = themeMode == ThemeMode.Dark, - onClick = { onThemeModeChange(ThemeMode.Dark) }, - ) - ThemeChoice( - icon = SettingsIcons.Sun, - title = stringResource(Res.string.appearance_light_mode), - selected = themeMode == ThemeMode.Light, - onClick = { onThemeModeChange(ThemeMode.Light) }, - ) - } -} - -@Composable -private fun AboutSettings( - deviceInfo: DeviceInfo, - coreState: CoreUiState, - onBack: () -> Unit, - showBack: Boolean, -) { - val unavailable = stringResource(Res.string.value_unavailable) - - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - SettingsTopBar(title = stringResource(Res.string.about_title), onBack = onBack, showBack = showBack) - SettingsGroup { - SettingsRow(icon = SettingsIcons.Document, title = stringResource(Res.string.about_privacy), iconTone = IconTone.Neutral) - SettingsRow(icon = SettingsIcons.Bug, title = stringResource(Res.string.about_bug_report), iconTone = IconTone.Neutral) - } - PlainInfoSection { - PlainInfoItem(stringResource(Res.string.version_title), "0.1.0") - PlainInfoItem(stringResource(Res.string.device_name_title), deviceInfo.deviceName.orUnavailable(unavailable)) - PlainInfoItem(stringResource(Res.string.device_model_title), deviceInfo.deviceModel.orUnavailable(unavailable)) - PlainInfoItem(stringResource(Res.string.os_version_title), deviceInfo.operatingSystem) - PlainInfoItem(stringResource(Res.string.network_title), deviceInfo.network.orUnavailable(unavailable)) - PlainInfoItem(stringResource(Res.string.battery_level_title), deviceInfo.batteryLevel.orUnavailable(unavailable)) - PlainInfoItem( - title = stringResource(Res.string.node_title), - value = if (coreState.isInitialized) { - stringResource(Res.string.core_status_ready) - } else { - stringResource(Res.string.not_initialized) - }, - ) - } - } -} - -@Composable -private fun SettingsLargeTitle(title: String) { - Text( - text = title, - style = MaterialTheme.typography.headlineLarge, - fontWeight = FontWeight.Bold, - ) -} - -@Composable -private fun SettingsTopBar(title: String, onBack: () -> Unit, showBack: Boolean) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - if (showBack) { - Icon( - imageVector = SettingsIcons.Back, - contentDescription = null, - tint = LocalVniDropColors.current.foregroundDefault, - modifier = Modifier - .size(30.dp) - .clickable(onClick = onBack) - .padding(3.dp), - ) - } - Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - } -} - -@Composable -private fun SettingsGroup(content: @Composable ColumnScope.() -> Unit) { - val colors = LocalVniDropColors.current - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(18.dp), - colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200), - ) { - Column(modifier = Modifier.padding(vertical = 4.dp), content = content) - } -} - -@Composable -private fun PlainInfoSection(content: @Composable ColumnScope.() -> Unit) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(18.dp), - content = content, - ) -} - -@Composable -private fun PlainInfoItem(title: String, value: String) { - val colors = LocalVniDropColors.current - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = title, - color = colors.foregroundLighter, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Medium, - ) - Text( - text = value, - color = colors.foregroundDefault, - style = MaterialTheme.typography.bodyLarge, - ) - } -} - -private fun String?.orUnavailable(fallback: String): String = - this?.takeIf { it.isNotBlank() } ?: fallback - -@Composable -private fun SettingsRow( - icon: ImageVector, - title: String, - value: String? = null, - selected: Boolean = false, - iconTone: IconTone = IconTone.Brand, - onClick: (() -> Unit)? = null, -) { - val colors = LocalVniDropColors.current - val iconColor = when (iconTone) { - IconTone.Brand -> colors.brandLink - IconTone.Neutral -> colors.foregroundLighter - } - Row( - modifier = Modifier - .fillMaxWidth() - .height(60.dp) - .background(if (selected) colors.backgroundSurface300 else Color.Transparent) - .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(icon, contentDescription = null, tint = iconColor, modifier = Modifier.size(22.dp)) - Spacer(Modifier.width(14.dp)) - Text( - text = title, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.bodyLarge, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - value?.let { - Text( - text = it, - color = colors.foregroundLighter, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.width(10.dp)) - } - if (onClick != null) { - Icon(SettingsIcons.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(20.dp)) - } - } -} - -@Composable -private fun ThemeChoice( - icon: ImageVector, - title: String, - selected: Boolean, - onClick: () -> Unit, - description: String? = null, -) { - val colors = LocalVniDropColors.current - val shape = RoundedCornerShape(18.dp) - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(62.dp) - .background(colors.backgroundSurface200, shape) - .border( - border = if (selected) BorderStroke(1.5.dp, colors.brandLink) else BorderStroke(1.dp, Color.Transparent), - shape = shape, - ) - .clickable(onClick = onClick) - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(icon, contentDescription = null, tint = colors.foregroundLight, modifier = Modifier.size(22.dp)) - Spacer(Modifier.width(14.dp)) - Text( - text = title, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - ) - if (selected) { - Icon(SettingsIcons.Check, contentDescription = null, tint = colors.brandLink, modifier = Modifier.size(24.dp)) - } - } - description?.let { - Text( - text = it, - color = colors.foregroundLighter, - style = MaterialTheme.typography.bodySmall, - ) - } - } -} - -@Composable -private fun ThemeMode.displayName(): String = - when (this) { - ThemeMode.System -> stringResource(Res.string.appearance_system_mode) - ThemeMode.Light -> stringResource(Res.string.appearance_light_mode) - ThemeMode.Dark -> stringResource(Res.string.appearance_dark_mode) - } - -private enum class IconTone { - Brand, - Neutral, -} - -private object SettingsIcons { - val ChevronRight = lineIcon("ChevronRight") { - moveTo(9f, 18f) - lineTo(15f, 12f) - lineTo(9f, 6f) - } - val Back = lineIcon("Back") { - moveTo(19f, 12f) - lineTo(5f, 12f) - moveTo(12f, 19f) - lineTo(5f, 12f) - lineTo(12f, 5f) - } - val Check = lineIcon("Check") { - moveTo(20f, 6f) - lineTo(9f, 17f) - lineTo(4f, 12f) - } - val Sun = lineIcon("Sun") { - moveTo(12f, 4f) - lineTo(12f, 2f) - moveTo(12f, 22f) - lineTo(12f, 20f) - moveTo(4.93f, 4.93f) - lineTo(6.34f, 6.34f) - moveTo(17.66f, 17.66f) - lineTo(19.07f, 19.07f) - moveTo(2f, 12f) - lineTo(4f, 12f) - moveTo(20f, 12f) - lineTo(22f, 12f) - moveTo(4.93f, 19.07f) - lineTo(6.34f, 17.66f) - moveTo(17.66f, 6.34f) - lineTo(19.07f, 4.93f) - moveTo(16f, 12f) - arcTo(4f, 4f, 0f, true, true, 8f, 12f) - arcTo(4f, 4f, 0f, true, true, 16f, 12f) - } - val Moon = lineIcon("Moon") { - moveTo(21f, 12.79f) - arcTo(9f, 9f, 0f, true, true, 11.21f, 3f) - arcTo(7f, 7f, 0f, false, false, 21f, 12.79f) - } - val Device = lineIcon("Device") { - roundRect(7f, 2f, 10f, 20f, 2.5f) - moveTo(11f, 18f) - lineTo(13f, 18f) - } - val Info = lineIcon("Info") { - moveTo(12f, 16f) - lineTo(12f, 12f) - moveTo(12f, 8f) - lineTo(12.01f, 8f) - moveTo(21f, 12f) - arcTo(9f, 9f, 0f, true, true, 3f, 12f) - arcTo(9f, 9f, 0f, true, true, 21f, 12f) - } - val Bug = lineIcon("Bug") { - moveTo(8f, 2f) - lineTo(9.88f, 3.88f) - moveTo(16f, 2f) - lineTo(14.12f, 3.88f) - roundRect(7f, 6f, 10f, 14f, 5f) - moveTo(3f, 10f) - lineTo(7f, 10f) - moveTo(17f, 10f) - lineTo(21f, 10f) - moveTo(3f, 16f) - lineTo(7f, 16f) - moveTo(17f, 16f) - lineTo(21f, 16f) - moveTo(12f, 6f) - lineTo(12f, 20f) - } - val Document = lineIcon("Document") { - moveTo(14f, 2f) - lineTo(6f, 2f) - arcTo(2f, 2f, 0f, false, false, 4f, 4f) - lineTo(4f, 20f) - arcTo(2f, 2f, 0f, false, false, 6f, 22f) - lineTo(18f, 22f) - arcTo(2f, 2f, 0f, false, false, 20f, 20f) - lineTo(20f, 8f) - lineTo(14f, 2f) - moveTo(14f, 2f) - lineTo(14f, 8f) - lineTo(20f, 8f) - moveTo(8f, 13f) - lineTo(16f, 13f) - moveTo(8f, 17f) - lineTo(16f, 17f) - } - val Node = lineIcon("Node") { - roundRect(4f, 4f, 16f, 16f, 3f) - moveTo(9f, 9f) - lineTo(15f, 9f) - moveTo(9f, 13f) - lineTo(15f, 13f) - moveTo(9f, 17f) - lineTo(12f, 17f) - } -} - -private fun lineIcon(name: String, block: androidx.compose.ui.graphics.vector.PathBuilder.() -> Unit): ImageVector = - ImageVector.Builder(name, 24.dp, 24.dp, 24f, 24f).apply { - path( - fill = SolidColor(Color.Transparent), - stroke = SolidColor(Color.Black), - strokeLineWidth = 2f, - strokeLineCap = StrokeCap.Round, - strokeLineJoin = StrokeJoin.Round, - pathFillType = PathFillType.NonZero, - pathBuilder = block, - ) - }.build() - -private fun androidx.compose.ui.graphics.vector.PathBuilder.roundRect(x: Float, y: Float, width: Float, height: Float, radius: Float) { - moveTo(x + radius, y) - lineTo(x + width - radius, y) - arcTo(radius, radius, 0f, false, true, x + width, y + radius) - lineTo(x + width, y + height - radius) - arcTo(radius, radius, 0f, false, true, x + width - radius, y + height) - lineTo(x + radius, y + height) - arcTo(radius, radius, 0f, false, true, x, y + height - radius) - lineTo(x, y + radius) - arcTo(radius, radius, 0f, false, true, x + radius, y) -} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt index d90230c..8cd455e 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt @@ -2,6 +2,7 @@ package com.vnidrop.app.ui.shell import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -22,14 +23,17 @@ import com.vnidrop.app.ui.theme.LocalVniDropColors @Composable fun AppShell( + modifier: Modifier = Modifier, selectedDestination: AppDestination, windowClass: WindowClass, onDestinationSelected: (AppDestination) -> Unit, + overlay: @Composable BoxScope.() -> Unit = {}, + floatingAction: (@Composable BoxScope.() -> Unit)? = null, content: @Composable () -> Unit, ) { val colors = LocalVniDropColors.current Surface( - modifier = Modifier + modifier = modifier .fillMaxSize() .background(colors.backgroundDashCanvas), color = colors.backgroundDashCanvas, @@ -38,12 +42,16 @@ fun AppShell( PhoneShell( selectedDestination = selectedDestination, onDestinationSelected = onDestinationSelected, + overlay = overlay, + floatingAction = floatingAction, content = content, ) } else { WideShell( selectedDestination = selectedDestination, onDestinationSelected = onDestinationSelected, + overlay = overlay, + floatingAction = floatingAction, content = content, ) } @@ -54,6 +62,8 @@ fun AppShell( private fun WideShell( selectedDestination: AppDestination, onDestinationSelected: (AppDestination) -> Unit, + overlay: @Composable BoxScope.() -> Unit, + floatingAction: (@Composable BoxScope.() -> Unit)?, content: @Composable () -> Unit, ) { Row(modifier = Modifier.fillMaxSize()) { @@ -61,7 +71,11 @@ private fun WideShell( selected = selectedDestination, onDestinationSelected = onDestinationSelected, ) - ScreenScrollContainer(modifier = Modifier.weight(1f), content = content) + Box(modifier = Modifier.weight(1f).fillMaxSize()) { + content() + floatingAction?.invoke(this) + Box(Modifier.fillMaxSize().padding(bottom = if (floatingAction == null) 0.dp else 72.dp)) { overlay() } + } } } @@ -69,10 +83,16 @@ private fun WideShell( private fun PhoneShell( selectedDestination: AppDestination, onDestinationSelected: (AppDestination) -> Unit, + overlay: @Composable BoxScope.() -> Unit, + floatingAction: (@Composable BoxScope.() -> Unit)?, content: @Composable () -> Unit, ) { Column(modifier = Modifier.fillMaxSize()) { - ScreenScrollContainer(modifier = Modifier.weight(1f), content = content) + Box(modifier = Modifier.weight(1f).fillMaxSize()) { + content() + floatingAction?.invoke(this) + Box(Modifier.fillMaxSize().padding(bottom = if (floatingAction == null) 0.dp else 72.dp)) { overlay() } + } AppBottomNavigation( selected = selectedDestination, onDestinationSelected = onDestinationSelected, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt index 6e34ad8..6f946c8 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt @@ -1,9 +1,8 @@ package com.vnidrop.app.ui.state -import com.vnidrop.app.ui.theme.ThemeMode -import com.vnidrop.app.ui.navigation.AppDestination -import uniffi.vnidrop.CoreEvent -import uniffi.vnidrop.StoredTransfer +import com.vnidrop.app.core.CoreEventModel +import com.vnidrop.app.core.Transfer +import com.vnidrop.app.core.TransferStatus import kotlin.math.roundToInt enum class WindowClass { @@ -22,38 +21,6 @@ fun windowClassFor(widthDp: Float): WindowClass = fun useBottomNavigation(windowClass: WindowClass): Boolean = windowClass == WindowClass.Phone -data class AppUiState( - val destination: AppDestination = AppDestination.Send, - val themeMode: ThemeMode = ThemeMode.System, -) - -data class SendUiState( - val selectedSource: String = "", - val selectedDisplayName: String = "", - val transferName: String = "VniDrop transfer", - val senderName: String = "", - val isSharing: Boolean = false, -) { - val hasSelectedSource: Boolean - get() = selectedSource.isNotBlank() - - fun canCreateShare(isCoreInitialized: Boolean): Boolean = - isCoreInitialized && hasSelectedSource && !isSharing -} - -data class ReceiveUiState( - val ticket: String = "", - val outputDirectory: String = "", - val receiverName: String = "", - val isReceiving: Boolean = false, -) { - fun canInspect(isCoreInitialized: Boolean): Boolean = - isCoreInitialized && ticket.isNotBlank() - - fun canReceive(isCoreInitialized: Boolean): Boolean = - isCoreInitialized && ticket.isNotBlank() && outputDirectory.isNotBlank() && !isReceiving -} - data class TransferProgress( val transferId: ULong?, val phase: String, @@ -61,18 +28,21 @@ data class TransferProgress( 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 displayNameForStatus(status: TransferStatus): String = + when (status) { + TransferStatus.Importing -> "Preparing" + TransferStatus.Sharing -> "Available" + TransferStatus.Receiving -> "Receiving" + TransferStatus.Done -> "Completed" + TransferStatus.Cancelled -> "Cancelled" + TransferStatus.Stopped -> "Stopped" + TransferStatus.Failed -> "Failed" } -fun summarizeProgress(events: List): List = +fun Transfer.isActiveTransfer(): Boolean = + status in activeTransferStatuses + +fun summarizeProgress(events: List): List = events .filter { event -> event.transferId != null && event.phase in progressPhases } .distinctBy { event -> "${event.transferId}:${event.phase}" } @@ -86,7 +56,7 @@ fun summarizeProgress(events: List): List = ) } -fun transferSubtitle(transfer: StoredTransfer): String { +fun transferSubtitle(transfer: Transfer): String { val pieces = listOfNotNull( transfer.transferName, "${transfer.fileCount} file${if (transfer.fileCount == 1UL) "" else "s"}", @@ -111,20 +81,10 @@ fun formatBytes(size: ULong): String { } } -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 val activeTransferStatuses = setOf(TransferStatus.Importing, TransferStatus.Sharing, TransferStatus.Receiving) -private fun eventLabel(event: CoreEvent): String { +private fun eventLabel(event: CoreEventModel): String { val direction = event.direction?.replaceFirstChar { it.uppercase() } val phase = event.phase.replaceFirstChar { it.uppercase() } val kind = event.kind.replace('-', ' ') diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt new file mode 100644 index 0000000..2755739 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -0,0 +1,247 @@ +package com.vnidrop.app.feature + +import com.vnidrop.app.DeviceInfo +import com.vnidrop.app.PlatformEnvironment +import com.vnidrop.app.core.CoreState +import com.vnidrop.app.core.ReceiveFolder +import com.vnidrop.app.core.ReceiveFolderKind +import com.vnidrop.app.core.Share +import com.vnidrop.app.core.ShareAccessPolicy +import com.vnidrop.app.feature.app.AppViewModel +import com.vnidrop.app.feature.receive.ReceiveViewModel +import com.vnidrop.app.feature.send.SendViewModel +import com.vnidrop.app.feature.settings.SettingsViewModel +import com.vnidrop.app.notifications.NotificationPermission +import com.vnidrop.app.preferences.AppPreferences +import com.vnidrop.app.support.FakeCoreGateway +import com.vnidrop.app.support.FakeFileSystemService +import com.vnidrop.app.support.FakeFilePreviewRepository +import com.vnidrop.app.support.FakeNotificationService +import com.vnidrop.app.support.FakePreferencesRepository +import com.vnidrop.app.ui.feedback.UiMessageController +import com.vnidrop.app.ui.navigation.AppDestination +import com.vnidrop.app.ui.theme.ThemeMode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertContentEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class ViewModelsTest { + @AfterTest + fun resetDispatcher() { + Dispatchers.resetMain() + } + + @Test + fun appViewModelInitializesCoreAndOwnsNavigation() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway() + val viewModel = AppViewModel(environment(), core, preferences(), UiMessageController()) + advanceUntilIdle() + assertTrue(core.state.value.isInitialized) + viewModel.selectDestination(AppDestination.Settings) + assertEquals(AppDestination.Settings, viewModel.state.value.destination) + } + + @Test + fun settingsEnablesNotificationsOnlyAfterPermission() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val preferences = preferences() + val notifications = FakeNotificationService(NotificationPermission.Granted) + val viewModel = SettingsViewModel( + environment(), + { DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") }, + FakeFileSystemService(folder), + preferences, + notifications, + UiMessageController(), + ) + advanceUntilIdle() + viewModel.setNotificationsEnabled(true) + advanceUntilIdle() + assertTrue(preferences.mutablePreferences.value.notificationsEnabled) + viewModel.setNotificationsEnabled(false) + advanceUntilIdle() + assertFalse(preferences.mutablePreferences.value.notificationsEnabled) + assertEquals(1, notifications.cancelAllCount) + } + + @Test + fun settingsKeepsNotificationsDisabledWhenPermissionIsDenied() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val preferences = preferences() + val viewModel = SettingsViewModel( + environment(), + { DeviceInfo("Device", null, "OS", null, null) }, + FakeFileSystemService(folder), + preferences, + FakeNotificationService(NotificationPermission.Denied), + UiMessageController(), + ) + advanceUntilIdle() + viewModel.setNotificationsEnabled(true) + advanceUntilIdle() + assertFalse(preferences.mutablePreferences.value.notificationsEnabled) + } + + @Test + fun settingsCompletesNotificationOptInAfterSystemSettingsGrant() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val preferences = preferences() + val notifications = FakeNotificationService(NotificationPermission.Denied) + val viewModel = SettingsViewModel( + environment(), + { DeviceInfo("Device", null, "OS", null, null) }, + FakeFileSystemService(folder), + preferences, + notifications, + UiMessageController(), + ) + advanceUntilIdle() + + viewModel.openNotificationSettings() + advanceUntilIdle() + assertEquals(1, notifications.openSettingsCount) + + notifications.mutablePermission.value = NotificationPermission.Granted + viewModel.refreshNotificationPermission() + advanceUntilIdle() + + assertTrue(preferences.mutablePreferences.value.notificationsEnabled) + assertEquals(NotificationPermission.Granted, viewModel.state.value.notificationPermission) + } + + @Test + fun settingsReportsUnsupportedNotificationPlatforms() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val preferences = preferences() + val viewModel = SettingsViewModel( + environment(), + { DeviceInfo("Device", null, "OS", null, null) }, + FakeFileSystemService(folder), + preferences, + FakeNotificationService(NotificationPermission.Unsupported), + UiMessageController(), + ) + advanceUntilIdle() + viewModel.setNotificationsEnabled(true) + advanceUntilIdle() + assertFalse(preferences.mutablePreferences.value.notificationsEnabled) + assertEquals(NotificationPermission.Unsupported, viewModel.state.value.notificationPermission) + } + + @Test + fun sendViewModelOwnsSelectedFileState() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController()) + viewModel.openComposer() + viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL)) + assertEquals("photo.jpg", viewModel.state.value.transferName) + assertEquals(42UL, viewModel.state.value.selectedFile?.sizeBytes) + viewModel.clearSelectedSource() + assertEquals(null, viewModel.state.value.selectedFile) + } + + @Test + fun sendComposerClosesAfterSuccessfulAtomicShareCreation() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + mutableState.value = CoreState(isInitialized = true) + shareResult = Result.success(Share(7UL, "ticket", "photo.jpg", "hash", 1UL, 42UL)) + } + val previews = FakeFilePreviewRepository() + val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), previews, UiMessageController()) + advanceUntilIdle() + viewModel.openComposer() + val thumbnail = ByteArray(12).also { + it[0] = 0x89.toByte(); it[1] = 'P'.code.toByte(); it[2] = 'N'.code.toByte(); it[3] = 'G'.code.toByte() + } + viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL, thumbnail)) + viewModel.setAccessPolicy(ShareAccessPolicy.AnyoneWithTransfer) + viewModel.createShare() + advanceUntilIdle() + + assertFalse(viewModel.state.value.isComposerOpen) + assertEquals(null, viewModel.state.value.selectedFile) + assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy) + assertEquals(7UL, core.state.value.transfers.first().transferId) + assertContentEquals(thumbnail, previews.previews.value.getValue(7UL)) + } + + @Test + fun sendComposerStaysOpenWhenShareCreationFails() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { mutableState.value = CoreState(isInitialized = true) } + val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController()) + advanceUntilIdle() + viewModel.openComposer() + viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL)) + viewModel.createShare() + advanceUntilIdle() + + assertTrue(viewModel.state.value.isComposerOpen) + assertEquals("photo.jpg", viewModel.state.value.selectedFile?.displayName) + assertFalse(viewModel.state.value.isSharing) + } + + @Test + fun sendDeletionRemovesCoreTransferAndOwnedPreview() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + mutableState.value = CoreState(isInitialized = true, transfers = listOf( + com.vnidrop.app.core.Transfer( + localId = "send-7", transferId = 7UL, + direction = com.vnidrop.app.core.TransferDirection.Send, + status = com.vnidrop.app.core.TransferStatus.Sharing, + peerId = null, transferName = "Photo", contentHash = "hash", + fileCount = 1UL, totalSize = 42UL, ticket = "ticket", + accessPolicy = ShareAccessPolicy.RequireApproval, createdAt = 1, updatedAt = 1, + ), + )) + } + val previews = FakeFilePreviewRepository() + previews.save(7UL, byteArrayOf(1, 2, 3)) + val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), previews, UiMessageController()) + advanceUntilIdle() + viewModel.openTransfer(7UL) + viewModel.requestDeleteTransfer() + viewModel.confirmDeleteTransfer() + advanceUntilIdle() + + assertEquals(listOf(7UL), core.deletedTransfers) + assertFalse(7UL in previews.previews.value) + assertEquals(null, viewModel.state.value.selectedTransferId) + assertFalse(viewModel.state.value.isDeleteConfirmationOpen) + } + + @Test + fun receiveViewModelBuildsStateFromPreferences() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { mutableState.value = mutableState.value.copy(isInitialized = true) } + val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController()) + advanceUntilIdle() + viewModel.setTicket("ticket") + assertTrue(viewModel.state.value.canReceive(coreInitialized = true)) + assertEquals("Receiver", viewModel.state.value.receiverName) + } + + private fun preferences() = FakePreferencesRepository( + AppPreferences("Receiver", folder, ThemeMode.System, notificationsEnabled = false), + ) + + private fun environment() = PlatformEnvironment("Test", "1.0", "/tmp/vnidrop") + + private companion object { + val folder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "Downloads") + } +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinatorTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinatorTest.kt new file mode 100644 index 0000000..4983f7d --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinatorTest.kt @@ -0,0 +1,123 @@ +package com.vnidrop.app.feature.approvals + +import com.vnidrop.app.core.CoreSignal +import com.vnidrop.app.core.CoreState +import com.vnidrop.app.core.ReceiverRequestModel +import com.vnidrop.app.core.ReceiverDeliveryStatus +import com.vnidrop.app.core.Transfer +import com.vnidrop.app.core.ReceiveFolder +import com.vnidrop.app.core.ReceiveFolderKind +import com.vnidrop.app.platform.AppVisibility +import com.vnidrop.app.preferences.AppPreferences +import com.vnidrop.app.support.FakeCoreGateway +import com.vnidrop.app.support.FakeNotificationService +import com.vnidrop.app.support.FakePreferencesRepository +import com.vnidrop.app.ui.feedback.UiMessageController +import com.vnidrop.app.ui.theme.ThemeMode +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class ApprovalCoordinatorTest { + @Test + fun ordersRequestsAndPublishesEachNotificationOnce() = runTest { + val core = FakeCoreGateway() + core.requests[1UL] = listOf(request("new", 20), request("old", 10)) + core.mutableState.value = CoreState(isInitialized = true, transfers = listOf(activeTransfer())) + val notifications = FakeNotificationService() + val visibility = AppVisibility(initiallyForeground = false) + val coordinator = ApprovalCoordinator(core, preferences(enabled = true), notifications, visibility, UiMessageController(), backgroundScope) + + runCurrent() + core.mutableSignals.emit(CoreSignal.ApprovalChanged(1UL)) + advanceUntilIdle() + assertEquals(listOf("old", "new"), coordinator.state.value.pending.map(PendingApproval::id)) + assertEquals(2, notifications.published.size) + + core.mutableSignals.emit(CoreSignal.ApprovalChanged(1UL)) + advanceUntilIdle() + assertEquals(2, notifications.published.size) + + visibility.setForeground(true) + runCurrent() + advanceUntilIdle() + assertTrue(notifications.cancelAllCount > 0) + + core.requests[1UL] = emptyList() + core.mutableSignals.emit(CoreSignal.ApprovalChanged(1UL)) + runCurrent() + advanceUntilIdle() + assertTrue(coordinator.state.value.pending.isEmpty()) + assertEquals(2, notifications.cancelled.size) + } + + @Test + fun failedResponseKeepsRequestVisible() = runTest { + val core = FakeCoreGateway().apply { + requests[1UL] = listOf(request("request", 10)) + mutableState.value = CoreState(isInitialized = true, transfers = listOf(activeTransfer())) + responseResult = Result.failure(IllegalStateException("database unavailable")) + } + val coordinator = ApprovalCoordinator( + core, + preferences(enabled = false), + FakeNotificationService(), + AppVisibility(), + UiMessageController(), + backgroundScope, + ) + runCurrent() + core.mutableSignals.emit(CoreSignal.ApprovalChanged(1UL)) + advanceUntilIdle() + coordinator.accept("request") + runCurrent() + advanceUntilIdle() + assertTrue(coordinator.state.value.pending.any { it.id == "request" }) + assertTrue(coordinator.state.value.respondingIds.isEmpty()) + } + + private fun request(id: String, requestedAt: Long) = ReceiverRequestModel( + id = id, + transferId = 1UL, + remoteEndpointId = "endpoint", + transferName = "Photos", + receiverName = "Peer", + receiverDeviceName = "Phone", + appVersion = "1.0", + status = ReceiverDeliveryStatus.Requested, + reason = null, + requestedAt = requestedAt, + respondedAt = null, + completedAt = null, + ) + + private fun activeTransfer() = Transfer( + localId = "local", + transferId = 1UL, + direction = com.vnidrop.app.core.TransferDirection.Send, + status = com.vnidrop.app.core.TransferStatus.Sharing, + peerId = null, + transferName = "Photos", + contentHash = "hash", + fileCount = 1UL, + totalSize = 1UL, + ticket = null, + accessPolicy = com.vnidrop.app.core.ShareAccessPolicy.RequireApproval, + createdAt = 1L, + updatedAt = 1L, + ) + + private fun preferences(enabled: Boolean) = FakePreferencesRepository( + AppPreferences( + username = "Sender", + receiveFolder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp"), + themeMode = ThemeMode.System, + notificationsEnabled = enabled, + ), + ) +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/send/FilePreviewRepositoryTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/send/FilePreviewRepositoryTest.kt new file mode 100644 index 0000000..b89042f --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/send/FilePreviewRepositoryTest.kt @@ -0,0 +1,68 @@ +package com.vnidrop.app.feature.send + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FilePreviewRepositoryTest { + @Test + fun preview_survives_repository_recreation() = runTest { + val store = MemoryPreviewStore() + AppFilePreviewRepository(store).save(42UL, png(12)) + + val restored = AppFilePreviewRepository(store) + restored.restore(setOf(42UL)) + + assertContentEquals(png(12), restored.previews.value.getValue(42UL)) + } + + @Test + fun restore_removes_orphans_and_corrupt_entries() = runTest { + val store = MemoryPreviewStore() + store.writeAtomically(1UL, png(12)) + store.writeAtomically(2UL, "not an image".encodeToByteArray()) + store.writeAtomically(3UL, png(12)) + + val repository = AppFilePreviewRepository(store) + repository.restore(setOf(1UL, 2UL)) + + assertEquals(setOf(1UL), repository.previews.value.keys) + assertFalse(store.entries.containsKey(2UL)) + assertFalse(store.entries.containsKey(3UL)) + } + + @Test + fun entry_and_total_limits_are_enforced() = runTest { + val store = MemoryPreviewStore() + val repository = AppFilePreviewRepository(store, PreviewStoragePolicy(maxEntryBytes = 20, maxTotalBytes = 32)) + + repository.save(1UL, png(17)) + store.clock += 1 + repository.save(2UL, png(17)) + store.clock += 1 + repository.save(3UL, png(21)) + + assertEquals(setOf(2UL), repository.previews.value.keys) + assertTrue(3UL !in store.entries) + } + + private fun png(size: Int): ByteArray = ByteArray(size.coerceAtLeast(8)).also { + it[0] = 0x89.toByte(); it[1] = 'P'.code.toByte(); it[2] = 'N'.code.toByte(); it[3] = 'G'.code.toByte() + } +} + +private class MemoryPreviewStore : PlatformPreviewStore { + data class Entry(val bytes: ByteArray, val modified: Long) + val entries = mutableMapOf() + var clock = 1L + override fun list() = entries.map { (id, entry) -> PreviewFileInfo(id, entry.bytes.size.toLong(), entry.modified) } + override fun read(transferId: ULong) = entries[transferId]?.bytes?.copyOf() + override fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean { + if (transferId !in entries) entries[transferId] = Entry(bytes.copyOf(), clock) + return true + } + override fun delete(transferId: ULong) { entries.remove(transferId) } +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/send/TransferShareActionsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/send/TransferShareActionsTest.kt new file mode 100644 index 0000000..328d98d --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/send/TransferShareActionsTest.kt @@ -0,0 +1,19 @@ +package com.vnidrop.app.feature.send + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class TransferShareActionsTest { + @Test + fun invitation_names_are_safe_and_have_the_vnd_extension() { + val name = invitationFileName("../Summer/photos: 2026") + assertEquals("_Summer_photos_ 2026.vnd", name) + assertFalse('/' in name) + } + + @Test + fun an_existing_extension_is_not_duplicated() { + assertEquals("Transfer.VND", invitationFileName("Transfer.VND")) + } +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt new file mode 100644 index 0000000..e8d8c74 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -0,0 +1,161 @@ +package com.vnidrop.app.support + +import com.vnidrop.app.core.CoreGateway +import com.vnidrop.app.core.CoreSignal +import com.vnidrop.app.core.CoreState +import com.vnidrop.app.core.FileSystemService +import com.vnidrop.app.core.FolderAccessStatus +import com.vnidrop.app.core.PickedShareFile +import com.vnidrop.app.core.ReceiveFolder +import com.vnidrop.app.core.ReceiverRequestModel +import com.vnidrop.app.core.Share +import com.vnidrop.app.core.ShareAccessPolicy +import com.vnidrop.app.core.TicketInspectionModel +import com.vnidrop.app.core.Transfer +import com.vnidrop.app.core.TransferDirection +import com.vnidrop.app.core.TransferStatus +import com.vnidrop.app.notifications.LocalNotification +import com.vnidrop.app.notifications.LocalNotificationService +import com.vnidrop.app.notifications.NotificationPermission +import com.vnidrop.app.preferences.AppPreferences +import com.vnidrop.app.preferences.PreferencesRepository +import com.vnidrop.app.feature.send.FilePreviewRepository +import com.vnidrop.app.ui.theme.ThemeMode +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import uniffi.vnidrop.ReceiveOutputSink + +class FakeCoreGateway : CoreGateway { + val mutableState = MutableStateFlow(CoreState()) + override val state: StateFlow = mutableState + val mutableSignals = MutableSharedFlow(extraBufferCapacity = 16) + override val signals: SharedFlow = mutableSignals + val requests = mutableMapOf>() + var responseResult: Result = Result.success(Unit) + val responses = mutableListOf>() + var shareResult: Result = Result.failure(UnsupportedOperationException()) + var deleteResult: Result = Result.success(Unit) + val deletedTransfers = mutableListOf() + var lastShareAccessPolicy: ShareAccessPolicy? = null + + override suspend fun initialize(appDataDir: String): Result { + mutableState.value = mutableState.value.copy(isInitialized = true) + return Result.success(Unit) + } + override fun shutdown() = Unit + override suspend fun sharePath(path: String, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy): Result { + lastShareAccessPolicy = accessPolicy + shareResult.onSuccess { share -> + mutableState.value = mutableState.value.copy( + transfers = listOf( + Transfer( + localId = "local-${share.transferId}", + transferId = share.transferId, + direction = TransferDirection.Send, + status = TransferStatus.Sharing, + peerId = null, + transferName = share.transferName, + contentHash = share.contentHash, + fileCount = share.fileCount, + totalSize = share.totalSize, + ticket = share.ticket, + accessPolicy = accessPolicy, + createdAt = 1L, + updatedAt = 1L, + ), + ) + mutableState.value.transfers, + ) + } + return shareResult + } + override suspend fun shareFileDescriptor( + fd: Int, + displayName: String, + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy, + ) = Result.failure(UnsupportedOperationException()) + override suspend fun shareSecurityScopedFileUrl( + fileUrl: String, + displayName: String, + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy, + ) = Result.failure(UnsupportedOperationException()) + override suspend fun inspectTicket(ticket: String) = Result.failure(UnsupportedOperationException()) + override suspend fun receive(ticket: String, outputDir: String, receiverName: String) = Result.success(Unit) + override suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = Result.success(Unit) + override suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) = Result.success(Unit) + override suspend fun cancel(transferId: ULong) = Result.success(Unit) + override suspend fun delete(transferId: ULong): Result { + if (deleteResult.isSuccess) { + deletedTransfers += transferId + mutableState.value = mutableState.value.copy( + transfers = mutableState.value.transfers.filterNot { it.transferId == transferId }, + ) + } + return deleteResult + } + override suspend fun receiverRequests(transferId: ULong) = Result.success(requests[transferId].orEmpty()) + override suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String?): Result { + responses += Triple(requestId, accepted, reason) + return responseResult + } + override suspend fun refresh() = Result.success(Unit) +} + +class FakePreferencesRepository( + initial: AppPreferences, +) : PreferencesRepository { + val mutablePreferences = MutableStateFlow(initial) + override val preferences = mutablePreferences + override suspend fun setUsername(username: String) { mutablePreferences.value = mutablePreferences.value.copy(username = username) } + override suspend fun setReceiveFolder(folder: ReceiveFolder) { mutablePreferences.value = mutablePreferences.value.copy(receiveFolder = folder) } + override suspend fun resetReceiveFolder() = Unit + override suspend fun setThemeMode(mode: ThemeMode) { mutablePreferences.value = mutablePreferences.value.copy(themeMode = mode) } + override suspend fun setNotificationsEnabled(enabled: Boolean) { mutablePreferences.value = mutablePreferences.value.copy(notificationsEnabled = enabled) } +} + +class FakeNotificationService( + permission: NotificationPermission = NotificationPermission.Granted, +) : LocalNotificationService { + val mutablePermission = MutableStateFlow(permission) + override val permission: StateFlow = mutablePermission + val published = mutableListOf() + val cancelled = mutableListOf() + var cancelAllCount = 0 + var openSettingsCount = 0 + var openSettingsResult: Result = Result.success(Unit) + override suspend fun refreshPermission() = permission.value + override suspend fun requestPermission() = permission.value + override suspend fun openSettings(): Result = openSettingsResult.also { openSettingsCount += 1 } + override suspend fun publish(notification: LocalNotification): Result = Result.success(Unit).also { published += notification } + override suspend fun cancel(id: String) { cancelled += id } + override suspend fun cancelAll() { cancelAllCount += 1 } +} + +class FakeFileSystemService( + private val folder: ReceiveFolder, +) : FileSystemService { + override fun defaultReceiveFolder() = folder + override suspend fun validateReceiveFolder(folder: ReceiveFolder) = FolderAccessStatus.Writable + override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null + override suspend fun sharePickedFile( + repository: CoreGateway, + file: PickedShareFile, + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy, + ) = repository.sharePath(file.value, transferName, senderName, accessPolicy) +} + +class FakeFilePreviewRepository : FilePreviewRepository { + private val state = MutableStateFlow>(emptyMap()) + override val previews: StateFlow> = state + val restored = mutableListOf>() + override suspend fun restore(activeTransferIds: Set) { restored += activeTransferIds } + override suspend fun save(transferId: ULong, bytes: ByteArray) { state.value = state.value + (transferId to bytes) } + override suspend fun remove(transferId: ULong) { state.value = state.value - transferId } +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/feedback/UiMessageControllerTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/feedback/UiMessageControllerTest.kt new file mode 100644 index 0000000..34f55f3 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/feedback/UiMessageControllerTest.kt @@ -0,0 +1,20 @@ +package com.vnidrop.app.ui.feedback + +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class UiMessageControllerTest { + @Test + fun messagesBufferAndPreserveFifoOrder() = runTest { + val controller = UiMessageController() + assertTrue(controller.tryShow(UiMessage(UiText.Dynamic("first")))) + assertTrue(controller.tryShow(UiMessage(UiText.Dynamic("second")))) + val collected = async { controller.messages.take(2).toList() }.await() + assertEquals(listOf("first", "second"), collected.map { (it.text as UiText.Dynamic).value }) + } +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt index d991a3a..3cefe44 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt @@ -1,5 +1,12 @@ package com.vnidrop.app.ui.state +import com.vnidrop.app.feature.receive.ReceiveState +import com.vnidrop.app.feature.send.SendState +import com.vnidrop.app.core.Transfer +import com.vnidrop.app.core.ShareAccessPolicy +import com.vnidrop.app.core.PickedShareFile +import com.vnidrop.app.core.TransferDirection +import com.vnidrop.app.core.TransferStatus import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.resolveDarkTheme import kotlin.test.Test @@ -30,18 +37,6 @@ class AppUiModelsTest { 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)) @@ -50,23 +45,57 @@ class AppUiModelsTest { @Test fun sendStateExposesShareEligibility() { - val ready = SendUiState(selectedSource = "/tmp/payload.txt") + val ready = SendState( + selectedFile = PickedShareFile("/tmp/payload.txt", "payload.txt", 128UL), + transferName = "payload.txt", + ) - assertTrue(ready.canCreateShare(isCoreInitialized = true)) - assertFalse(ready.canCreateShare(isCoreInitialized = false)) - assertFalse(SendUiState().canCreateShare(isCoreInitialized = true)) - assertFalse(ready.copy(isSharing = true).canCreateShare(isCoreInitialized = true)) + assertTrue(ready.canCreateShare(coreInitialized = true)) + assertFalse(ready.canCreateShare(coreInitialized = false)) + assertFalse(SendState().canCreateShare(coreInitialized = true)) + assertFalse(ready.copy(isSharing = true).canCreateShare(coreInitialized = true)) } @Test fun receiveStateExposesInspectAndReceiveEligibility() { - val ready = ReceiveUiState(ticket = "ticket", outputDirectory = "/tmp/out") + val ready = ReceiveState( + ticket = "ticket", + outputDirectory = "/tmp/out", + folderAccessStatus = com.vnidrop.app.core.FolderAccessStatus.Writable, + ) - assertTrue(ready.canInspect(isCoreInitialized = true)) - assertTrue(ready.canReceive(isCoreInitialized = true)) - assertFalse(ready.canInspect(isCoreInitialized = false)) - assertFalse(ready.copy(ticket = "").canReceive(isCoreInitialized = true)) - assertFalse(ready.copy(outputDirectory = "").canReceive(isCoreInitialized = true)) - assertFalse(ready.copy(isReceiving = true).canReceive(isCoreInitialized = true)) + assertTrue(ready.canInspect(coreInitialized = true)) + assertTrue(ready.canReceive(coreInitialized = true)) + assertFalse(ready.canInspect(coreInitialized = false)) + assertFalse(ready.copy(ticket = "").canReceive(coreInitialized = true)) + assertFalse(ready.copy(outputDirectory = "").canReceive(coreInitialized = true)) + assertFalse(ready.copy(isReceiving = true).canReceive(coreInitialized = true)) } + + @Test + fun transferActivityOnlyIncludesRunningStatuses() { + assertTrue(storedTransfer(status = TransferStatus.Importing).isActiveTransfer()) + assertTrue(storedTransfer(status = TransferStatus.Sharing).isActiveTransfer()) + assertTrue(storedTransfer(status = TransferStatus.Receiving).isActiveTransfer()) + assertFalse(storedTransfer(status = TransferStatus.Done).isActiveTransfer()) + assertFalse(storedTransfer(status = TransferStatus.Failed).isActiveTransfer()) + assertFalse(storedTransfer(status = TransferStatus.Cancelled).isActiveTransfer()) + } + + private fun storedTransfer(status: TransferStatus): Transfer = + Transfer( + localId = "local-1", + transferId = 1UL, + peerId = null, + direction = TransferDirection.Send, + status = status, + transferName = "Demo", + contentHash = "hash", + ticket = null, + fileCount = 1UL, + totalSize = 128UL, + accessPolicy = ShareAccessPolicy.RequireApproval, + createdAt = 1L, + updatedAt = 1L, + ) } diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/MainViewController.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/MainViewController.kt index 7fa0979..3a04e5e 100644 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/MainViewController.kt +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/MainViewController.kt @@ -2,4 +2,4 @@ package com.vnidrop.app import androidx.compose.ui.window.ComposeUIViewController -fun MainViewController() = ComposeUIViewController { App() } \ No newline at end of file +fun MainViewController() = ComposeUIViewController { App(rememberIosAppDependencies()) } diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/Platform.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/Platform.ios.kt index 7934fb8..2178715 100644 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/Platform.ios.kt +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/Platform.ios.kt @@ -1,28 +1,55 @@ package com.vnidrop.app -import platform.Foundation.NSTemporaryDirectory +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vnidrop.app.core.rememberFileSystemService +import com.vnidrop.app.notifications.IosLocalNotificationService +import platform.Foundation.NSBundle +import platform.Foundation.NSApplicationSupportDirectory +import platform.Foundation.NSSearchPathForDirectoriesInDomains +import platform.Foundation.NSUserDomainMask import platform.UIKit.UIDevice -class IOSPlatform : Platform { - private val device: UIDevice = UIDevice.currentDevice +@Composable +fun rememberIosAppDependencies(): AppDependencies { + val fileSystemService = rememberFileSystemService() + return remember(fileSystemService) { + val device = UIDevice.currentDevice + AppDependencies( + environment = PlatformEnvironment( + name = device.systemName() + " " + device.systemVersion, + appVersion = NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String ?: "0.1.0", + defaultCoreDataDir = iosApplicationDataDirectory(), + defaultUsername = device.name.takeIf(String::isNotBlank) ?: "Receiver", + ), + deviceInfoProvider = IosDeviceInfoProvider(device), + fileSystemService = fileSystemService, + localNotificationService = IosLocalNotificationService(), + ) + } +} - override val name: String = device.systemName() + " " + device.systemVersion - override val defaultCoreDataDir: String = NSTemporaryDirectory() + "vnidrop" - override val defaultReceiveDir: String = NSTemporaryDirectory() + "vnidrop-receive" - override val deviceInfo: DeviceInfo = DeviceInfo( +private fun iosApplicationDataDirectory(): String = + (NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true).firstOrNull() as? String) + ?.trimEnd('/')?.plus("/VniDrop") + ?: error("iOS Application Support directory is unavailable") + +private class IosDeviceInfoProvider( + private val device: UIDevice, +) : DeviceInfoProvider { + override suspend fun load(): DeviceInfo = DeviceInfo( deviceName = device.name, deviceModel = device.model, operatingSystem = device.systemName() + " " + device.systemVersion, network = null, - batteryLevel = batteryLevel(device), + batteryLevel = runCatching { + val wasMonitoring = device.batteryMonitoringEnabled + try { + device.batteryMonitoringEnabled = true + device.batteryLevel.takeIf { it >= 0.0 }?.let { "${(it * 100).toInt()}%" } + } finally { + device.batteryMonitoringEnabled = wasMonitoring + } + }.getOrNull(), ) } - -actual fun getPlatform(): Platform = IOSPlatform() - -private fun batteryLevel(device: UIDevice): String? = - runCatching { - device.batteryMonitoringEnabled = true - val level = device.batteryLevel - if (level >= 0.0) "${(level * 100).toInt()}%" else null - }.getOrNull() diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt index 134dac9..52499cb 100644 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt @@ -3,11 +3,19 @@ package com.vnidrop.app.core import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.readBytes import platform.Foundation.NSURL +import platform.Foundation.NSFileManager +import platform.Foundation.NSFileSize +import platform.Foundation.NSNumber import platform.UIKit.UIApplication import platform.UIKit.UIDocumentPickerDelegateProtocol import platform.UIKit.UIDocumentPickerViewController +import platform.UIKit.UIDocumentInteractionController +import platform.UIKit.UIImage +import platform.UIKit.UIImagePNGRepresentation import platform.UIKit.UIModalPresentationFormSheet +import platform.UniformTypeIdentifiers.UTTypeFolder import platform.UniformTypeIdentifiers.UTTypeItem import platform.darwin.NSObject @@ -37,13 +45,39 @@ actual fun rememberShareFilePicker( } } -actual suspend fun sharePickedFile( - repository: CoreRepository, - file: PickedShareFile, - transferName: String, - senderName: String, -) { - repository.shareSecurityScopedFileUrl(file.value, file.displayName, transferName, senderName) +@Composable +actual fun rememberReceiveFolderPicker( + onFolderPicked: (ReceiveFolder) -> Unit, + onError: (String) -> Unit, +): ReceiveFolderPicker = remember(onFolderPicked, onError) { + object : ReceiveFolderPicker { + @OptIn(ExperimentalForeignApi::class) + override fun pickFolder() { + val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController + if (presenter == null) { + onError("Could not find an iOS view controller for the folder picker") + return + } + + val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder), asCopy = false) + val delegate = DocumentPickerDelegate( + onFilePicked = { folder -> + onFolderPicked( + ReceiveFolder( + kind = ReceiveFolderKind.IosSecurityScopedUrl, + value = folder.value, + displayName = folder.displayName, + ), + ) + }, + onError = onError, + ) + retainedPickerDelegate = delegate + picker.delegate = delegate + picker.modalPresentationStyle = UIModalPresentationFormSheet + presenter.presentViewController(picker, animated = true, completion = null) + } + } } private class DocumentPickerDelegate( @@ -56,7 +90,21 @@ private class DocumentPickerDelegate( onError("The selected iOS document URL was invalid") } else { val displayName = url.lastPathComponent ?: "transfer" - onFilePicked(PickedShareFile(url.absoluteString ?: url.path.orEmpty(), displayName)) + val didStartAccess = url.startAccessingSecurityScopedResource() + val sizeBytes = try { + val attributes = url.path?.let { NSFileManager.defaultManager.attributesOfItemAtPath(it, null) } + (attributes?.get(NSFileSize) as? NSNumber)?.unsignedLongLongValue + } finally { + if (didStartAccess) url.stopAccessingSecurityScopedResource() + } + onFilePicked( + PickedShareFile( + url.absoluteString ?: url.path.orEmpty(), + displayName, + sizeBytes, + nativeFileIcon(url), + ), + ) } retainedPickerDelegate = null } @@ -65,3 +113,11 @@ private class DocumentPickerDelegate( retainedPickerDelegate = null } } + +@OptIn(ExperimentalForeignApi::class) +private fun nativeFileIcon(url: NSURL): ByteArray? = runCatching { + val controller = UIDocumentInteractionController.interactionControllerWithURL(url) + val icon = controller.icons.lastOrNull() as? UIImage ?: return null + val data = UIImagePNGRepresentation(icon) ?: return null + data.bytes?.readBytes(data.length.toInt()) +}.getOrNull() diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt new file mode 100644 index 0000000..4a1e901 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt @@ -0,0 +1,73 @@ +package com.vnidrop.app.core + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import platform.Foundation.NSFileManager +import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSSearchPathForDirectoriesInDomains +import platform.Foundation.NSURL +import platform.Foundation.NSUserDomainMask +import uniffi.vnidrop.ReceiveOutputSink + +@Composable +actual fun rememberFileSystemService(): FileSystemService = + remember { IosFileSystemService() } + +private class IosFileSystemService : FileSystemService { + override fun defaultReceiveFolder(): ReceiveFolder { + val path = NSSearchPathForDirectoriesInDomains( + NSDocumentDirectory, + NSUserDomainMask, + true, + ).firstOrNull() as? String ?: "" + return ReceiveFolder( + kind = ReceiveFolderKind.FileSystemPath, + value = path, + displayName = "Documents", + ) + } + + override suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus = + when (folder.kind) { + ReceiveFolderKind.FileSystemPath -> { + if (NSFileManager.defaultManager.isWritableFileAtPath(folder.value)) { + FolderAccessStatus.Writable + } else { + FolderAccessStatus.Unavailable + } + } + ReceiveFolderKind.IosSecurityScopedUrl -> validateSecurityScopedUrl(folder.value) + ReceiveFolderKind.AndroidTreeUri -> FolderAccessStatus.Unavailable + } + + override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null + + override suspend fun sharePickedFile( + repository: CoreGateway, + file: PickedShareFile, + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy, + ): Result = repository.shareSecurityScopedFileUrl( + file.value, + file.displayName, + transferName, + senderName, + accessPolicy, + ) + + private fun validateSecurityScopedUrl(value: String): FolderAccessStatus { + val url = NSURL.URLWithString(value) ?: NSURL.fileURLWithPath(value) + val didStartAccess = url.startAccessingSecurityScopedResource() + return try { + val path = url.path + if (path != null && NSFileManager.defaultManager.isWritableFileAtPath(path)) { + FolderAccessStatus.Writable + } else { + FolderAccessStatus.PermissionRequired + } + } finally { + if (didStartAccess) url.stopAccessingSecurityScopedResource() + } + } +} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.ios.kt new file mode 100644 index 0000000..deffba1 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.ios.kt @@ -0,0 +1,62 @@ +package com.vnidrop.app.feature.send + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.readBytes +import kotlinx.cinterop.usePinned +import platform.Foundation.NSData +import platform.Foundation.NSDate +import platform.Foundation.NSFileManager +import platform.Foundation.NSFileModificationDate +import platform.Foundation.NSFileSize +import platform.Foundation.NSNumber +import platform.Foundation.create +import platform.Foundation.dataWithContentsOfFile +import platform.Foundation.timeIntervalSince1970 +import platform.Foundation.writeToFile + +actual fun createPlatformPreviewStore(appDataDir: String): PlatformPreviewStore = + IosPreviewStore(appDataDir.trimEnd('/') + "/ui/previews") + +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +private class IosPreviewStore(private val directory: String) : PlatformPreviewStore { + private val files = NSFileManager.defaultManager + + override fun list(): List { + ensureDirectory() + return files.contentsOfDirectoryAtPath(directory, null).orEmpty().filterIsInstance().mapNotNull { name -> + val id = name.removeSuffix(".preview").toULongOrNull() ?: return@mapNotNull null + val attributes = files.attributesOfItemAtPath("$directory/$name", null) ?: return@mapNotNull null + val size = (attributes[NSFileSize] as? NSNumber)?.longLongValue ?: 0L + val modified = ((attributes[NSFileModificationDate] as? NSDate)?.timeIntervalSince1970 ?: 0.0) * 1000.0 + PreviewFileInfo(id, size, modified.toLong()) + } + } + + override fun read(transferId: ULong): ByteArray? { + val data = NSData.dataWithContentsOfFile(path(transferId)) ?: return null + return data.bytes?.readBytes(data.length.toInt()) + } + + override fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean { + ensureDirectory() + if (files.fileExistsAtPath(path(transferId))) return true + val temporary = "$directory/.$transferId.tmp" + val data = bytes.usePinned { pinned -> NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong()) } + if (!data.writeToFile(temporary, atomically = true)) return false + val moved = files.moveItemAtPath(temporary, path(transferId), null) + if (!moved) files.removeItemAtPath(temporary, null) + return moved + } + + override fun delete(transferId: ULong) { + files.removeItemAtPath(path(transferId), null) + } + + private fun ensureDirectory() { + files.createDirectoryAtPath(directory, withIntermediateDirectories = true, attributes = null, error = null) + } + + private fun path(transferId: ULong) = "$directory/$transferId.preview" +} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.ios.kt new file mode 100644 index 0000000..ca9be15 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.ios.kt @@ -0,0 +1,66 @@ +package com.vnidrop.app.feature.send + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.BetaInteropApi +import platform.Foundation.NSString +import platform.Foundation.NSTemporaryDirectory +import platform.Foundation.NSURL +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.create +import platform.Foundation.writeToFile +import platform.UIKit.UIActivityViewController +import platform.UIKit.UIApplication +import platform.UIKit.UIDocumentPickerViewController +import platform.UIKit.UIModalPresentationFormSheet + +@OptIn(ExperimentalForeignApi::class) +@Composable +actual fun rememberTransferShareActions(): TransferShareActions = remember { + object : TransferShareActions { + override val canUseNativeShare = true + // Core NFC tag writing requires the NFC entitlement. Keep the action + // visible but disabled until that capability is provisioned for the app. + override val nfcAvailability = NfcShareAvailability.Unavailable + + override fun exportInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) { + onResult(runCatching { + val url = createInvitation(ticket, transferName) + val picker = UIDocumentPickerViewController(forExportingURLs = listOf(url), asCopy = true) + present(picker) + }) + } + + override fun shareInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) { + onResult(runCatching { + val url = createInvitation(ticket, transferName) + val controller = UIActivityViewController(activityItems = listOf(url), applicationActivities = null) + controller.modalPresentationStyle = UIModalPresentationFormSheet + presenter().presentViewController(controller, animated = true, completion = null) + }) + } + + override fun writeInvitationToNfc(ticket: String, onResult: (Result) -> Unit) { + onResult(Result.failure(UnsupportedOperationException("NFC tag writing is not enabled for this build"))) + } + override fun cancelNfcWrite() = Unit + } +} + +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +private fun createInvitation(ticket: String, transferName: String): NSURL { + val path = NSTemporaryDirectory().trimEnd('/') + "/" + invitationFileName(transferName) + val text = NSString.create(string = ticket) + require(text.writeToFile(path, atomically = true, encoding = NSUTF8StringEncoding, error = null)) { + "The invitation file could not be created" + } + return NSURL.fileURLWithPath(path) +} + +private fun presenter() = UIApplication.sharedApplication.keyWindow?.rootViewController + ?: error("Could not find an iOS view controller") + +private fun present(controller: platform.UIKit.UIViewController) { + presenter().presentViewController(controller, animated = true, completion = null) +} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.ios.kt new file mode 100644 index 0000000..9d52847 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.ios.kt @@ -0,0 +1,99 @@ +package com.vnidrop.app.notifications + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import platform.Foundation.NSURL +import platform.UIKit.UIApplication +import platform.UIKit.UIApplicationOpenNotificationSettingsURLString +import platform.UserNotifications.UNAuthorizationOptionAlert +import platform.UserNotifications.UNAuthorizationOptionSound +import platform.UserNotifications.UNAuthorizationStatusAuthorized +import platform.UserNotifications.UNAuthorizationStatusDenied +import platform.UserNotifications.UNAuthorizationStatusEphemeral +import platform.UserNotifications.UNAuthorizationStatusNotDetermined +import platform.UserNotifications.UNAuthorizationStatusProvisional +import platform.UserNotifications.UNMutableNotificationContent +import platform.UserNotifications.UNNotificationRequest +import platform.UserNotifications.UNUserNotificationCenter +import kotlin.coroutines.resume + +class IosLocalNotificationService : LocalNotificationService { + private val center = UNUserNotificationCenter.currentNotificationCenter() + private val _permission = MutableStateFlow(NotificationPermission.NotDetermined) + override val permission: StateFlow = _permission.asStateFlow() + + override suspend fun refreshPermission(): NotificationPermission = suspendCancellableCoroutine { continuation -> + center.getNotificationSettingsWithCompletionHandler { settings -> + val mapped = when (settings?.authorizationStatus) { + UNAuthorizationStatusAuthorized, + UNAuthorizationStatusProvisional, + UNAuthorizationStatusEphemeral -> NotificationPermission.Granted + UNAuthorizationStatusDenied -> NotificationPermission.Denied + UNAuthorizationStatusNotDetermined -> NotificationPermission.NotDetermined + else -> NotificationPermission.Unsupported + } + _permission.value = mapped + if (continuation.isActive) continuation.resume(mapped) + } + } + + override suspend fun requestPermission(): NotificationPermission { + val current = refreshPermission() + if (current != NotificationPermission.NotDetermined) return current + return suspendCancellableCoroutine { continuation -> + center.requestAuthorizationWithOptions( + options = UNAuthorizationOptionAlert or UNAuthorizationOptionSound, + completionHandler = { granted, _ -> + val result = if (granted) NotificationPermission.Granted else NotificationPermission.Denied + _permission.value = result + if (continuation.isActive) continuation.resume(result) + }, + ) + } + } + + override suspend fun openSettings(): Result { + val url = NSURL.URLWithString(UIApplicationOpenNotificationSettingsURLString) + ?: return Result.failure(IllegalStateException("Notification settings URL is unavailable")) + val opened = suspendCancellableCoroutine { continuation -> + UIApplication.sharedApplication.openURL(url, emptyMap()) { success -> + if (continuation.isActive) continuation.resume(success) + } + } + return if (opened) Result.success(Unit) else Result.failure(IllegalStateException("Could not open notification settings")) + } + + override suspend fun publish(notification: LocalNotification): Result = runCatching { + check(refreshPermission() == NotificationPermission.Granted) { "Notification permission is not granted" } + val content = UNMutableNotificationContent().apply { + setTitle(notification.title) + setBody(notification.body) + setSound(platform.UserNotifications.UNNotificationSound.defaultSound) + } + val request = UNNotificationRequest.requestWithIdentifier(notification.id, content, null) + suspendCancellableCoroutine { continuation -> + center.addNotificationRequest(request) { error -> + if (!continuation.isActive) return@addNotificationRequest + if (error == null) { + continuation.resume(Unit) + } else { + continuation.resumeWith( + Result.failure(IllegalStateException(error.localizedDescription)), + ) + } + } + } + } + + override suspend fun cancel(id: String) { + center.removePendingNotificationRequestsWithIdentifiers(listOf(id)) + center.removeDeliveredNotificationsWithIdentifiers(listOf(id)) + } + + override suspend fun cancelAll() { + center.removeAllPendingNotificationRequests() + center.removeAllDeliveredNotifications() + } +} diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt index 085fa32..cef6d80 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt @@ -1,37 +1,42 @@ package com.vnidrop.app +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vnidrop.app.core.rememberFileSystemService +import com.vnidrop.app.notifications.JvmLocalNotificationService import java.net.NetworkInterface -class JVMPlatform : Platform { - override val name: String = "Java ${System.getProperty("java.version")}" - override val defaultCoreDataDir: String = - System.getProperty("user.home") + "/.vnidrop" - override val defaultReceiveDir: String = - System.getProperty("user.home") + "/Downloads" - override val deviceInfo: DeviceInfo = DeviceInfo( - deviceName = System.getenv("COMPUTERNAME") - ?: System.getenv("HOSTNAME") - ?: System.getProperty("user.name"), - deviceModel = listOfNotNull( - System.getProperty("os.arch"), - System.getProperty("java.vm.name"), - ).joinToString(" | ").ifBlank { null }, - operatingSystem = listOfNotNull( - System.getProperty("os.name"), - System.getProperty("os.version"), - ).joinToString(" ").ifBlank { name }, - network = activeNetworkSummary(), +@Composable +fun rememberJvmAppDependencies(): AppDependencies { + val fileSystemService = rememberFileSystemService() + return remember(fileSystemService) { + AppDependencies( + environment = PlatformEnvironment( + name = "Java ${System.getProperty("java.version")}", + appVersion = AppDependencies::class.java.`package`.implementationVersion ?: "0.1.0", + defaultCoreDataDir = System.getProperty("user.home") + "/.vnidrop", + defaultUsername = System.getenv("COMPUTERNAME") ?: System.getenv("HOSTNAME") ?: System.getProperty("user.name") ?: "Receiver", + ), + deviceInfoProvider = JvmDeviceInfoProvider, + fileSystemService = fileSystemService, + localNotificationService = JvmLocalNotificationService(), + ) + } +} + +private object JvmDeviceInfoProvider : DeviceInfoProvider { + override suspend fun load(): DeviceInfo = DeviceInfo( + deviceName = System.getenv("COMPUTERNAME") ?: System.getenv("HOSTNAME") ?: System.getProperty("user.name"), + deviceModel = listOfNotNull(System.getProperty("os.arch"), System.getProperty("java.vm.name")) + .joinToString(" | ").ifBlank { null }, + operatingSystem = listOfNotNull(System.getProperty("os.name"), System.getProperty("os.version")) + .joinToString(" ").ifBlank { "Java ${System.getProperty("java.version")}" }, + network = runCatching { + NetworkInterface.getNetworkInterfaces().asSequence() + .filter { it.isUp && !it.isLoopback } + .map { it.displayName } + .firstOrNull() + }.getOrNull(), batteryLevel = null, ) } - -actual fun getPlatform(): Platform = JVMPlatform() - -private fun activeNetworkSummary(): String? = - runCatching { - NetworkInterface.getNetworkInterfaces() - .asSequence() - .filter { it.isUp && !it.isLoopback } - .map { it.displayName } - .firstOrNull() - }.getOrNull() diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt index 03a9bb9..c7b5198 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt @@ -2,9 +2,16 @@ package com.vnidrop.app.core import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import java.awt.EventQueue import java.awt.FileDialog import java.awt.Frame +import java.awt.KeyboardFocusManager +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream import java.io.File +import javax.imageio.ImageIO +import javax.swing.JFileChooser +import javax.swing.filechooser.FileSystemView @Composable actual fun rememberShareFilePicker( @@ -13,27 +20,132 @@ actual fun rememberShareFilePicker( ): ShareFilePicker = remember(onFilePicked, onError) { object : ShareFilePicker { override fun pickFile() { - try { - val dialog = FileDialog(null as Frame?, "Select file to share", FileDialog.LOAD) - dialog.isVisible = true - val directory = dialog.directory - val file = dialog.file - if (directory != null && file != null) { - val selected = File(directory, file) - onFilePicked(PickedShareFile(selected.absolutePath, selected.name)) - } - } catch (error: Throwable) { - onError(error.message ?: error.toString()) + openPicker(onError) { + pickShareFile()?.let(onFilePicked) } } } } -actual suspend fun sharePickedFile( - repository: CoreRepository, - file: PickedShareFile, - transferName: String, - senderName: String, -) { - repository.sharePath(file.value, transferName, senderName) +@Composable +actual fun rememberReceiveFolderPicker( + onFolderPicked: (ReceiveFolder) -> Unit, + onError: (String) -> Unit, +): ReceiveFolderPicker = remember(onFolderPicked, onError) { + object : ReceiveFolderPicker { + override fun pickFolder() { + openPicker(onError) { + val selected = pickDirectory() ?: return@openPicker + onFolderPicked( + ReceiveFolder( + kind = ReceiveFolderKind.FileSystemPath, + value = selected.absolutePath, + displayName = selected.name.ifBlank { selected.absolutePath }, + ), + ) + } + } + } } + +private fun openPicker( + onError: (String) -> Unit, + block: () -> Unit, +) { + EventQueue.invokeLater { + try { + block() + } catch (error: Throwable) { + onError(error.message ?: error.toString()) + } + } +} + +private fun nativeFileDialog(title: String): FileDialog = + FileDialog(activeFrame(), title, FileDialog.LOAD) + +private fun activeFrame(): Frame? { + val activeWindow = KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow + return activeWindow as? Frame + ?: Frame.getFrames().firstOrNull { it.isActive || it.isFocused } + ?: Frame.getFrames().firstOrNull { it.isVisible } +} + +private fun pickShareFile(): PickedShareFile? { + val dialog = nativeFileDialog("Select file to share") + return try { + dialog.isVisible = true + val directory = dialog.directory + val file = dialog.file + if (directory != null && file != null) { + val selected = File(directory, file) + PickedShareFile( + selected.absolutePath, + selected.name, + selected.length().takeIf { it >= 0L }?.toULong(), + selected.systemIconPng(), + ) + } else { + null + } + } finally { + dialog.dispose() + } +} + +private fun File.systemIconPng(): ByteArray? = runCatching { + val icon = FileSystemView.getFileSystemView().getSystemIcon(this, 128, 128) + val image = BufferedImage(icon.iconWidth, icon.iconHeight, BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + try { + icon.paintIcon(null, graphics, 0, 0) + } finally { + graphics.dispose() + } + ByteArrayOutputStream().use { output -> + ImageIO.write(image, "png", output) + output.toByteArray() + } +}.getOrNull() + +private fun pickDirectory(): File? = + if (isMacOs()) { + val dialog = withMacDirectoryDialog { + nativeFileDialog("Select receive folder").apply { isVisible = true } + } + try { + val directory = dialog.directory ?: return null + dialog.file + ?.let { File(directory, it) } + ?: File(directory) + } finally { + dialog.dispose() + } + } else { + val chooser = JFileChooser().apply { + dialogTitle = "Select receive folder" + fileSelectionMode = JFileChooser.DIRECTORIES_ONLY + isAcceptAllFileFilterUsed = false + } + if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) chooser.selectedFile else null + } + +private fun withMacDirectoryDialog(block: () -> T): T { + if (!isMacOs()) return block() + + val key = "apple.awt.fileDialogForDirectories" + val previous = System.getProperty(key) + System.setProperty(key, "true") + return try { + block() + } finally { + if (previous == null) { + System.clearProperty(key) + } else { + System.setProperty(key, previous) + } + } +} + +private fun isMacOs(): Boolean = + System.getProperty("os.name").startsWith("Mac", ignoreCase = true) diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt new file mode 100644 index 0000000..b882401 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt @@ -0,0 +1,38 @@ +package com.vnidrop.app.core + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import uniffi.vnidrop.ReceiveOutputSink +import java.io.File + +@Composable +actual fun rememberFileSystemService(): FileSystemService = + remember { JvmFileSystemService() } + +private class JvmFileSystemService : FileSystemService { + override fun defaultReceiveFolder(): ReceiveFolder = + ReceiveFolder( + kind = ReceiveFolderKind.FileSystemPath, + value = File(System.getProperty("user.home"), "Downloads").absolutePath, + displayName = "Downloads", + ) + + override suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus { + if (folder.kind != ReceiveFolderKind.FileSystemPath) return FolderAccessStatus.Unavailable + return runCatching { + val directory = File(folder.value) + if (!directory.exists()) directory.mkdirs() + if (directory.isDirectory && directory.canWrite()) FolderAccessStatus.Writable else FolderAccessStatus.Unavailable + }.getOrDefault(FolderAccessStatus.Unavailable) + } + + override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null + + override suspend fun sharePickedFile( + repository: CoreGateway, + file: PickedShareFile, + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy, + ): Result = repository.sharePath(file.value, transferName, senderName, accessPolicy) +} diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.jvm.kt new file mode 100644 index 0000000..9d8fb51 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.jvm.kt @@ -0,0 +1,22 @@ +package com.vnidrop.app.feature.send + +import java.io.File + +actual fun createPlatformPreviewStore(appDataDir: String): PlatformPreviewStore = JvmPreviewStore(File(appDataDir, "ui/previews")) + +private class JvmPreviewStore(private val directory: File) : PlatformPreviewStore { + override fun list(): List = directory.listFiles().orEmpty().mapNotNull { file -> + file.name.removeSuffix(".preview").toULongOrNull()?.let { PreviewFileInfo(it, file.length(), file.lastModified()) } + } + override fun read(transferId: ULong): ByteArray? = runCatching { file(transferId).takeIf(File::isFile)?.readBytes() }.getOrNull() + override fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean = runCatching { + directory.mkdirs() + val target = file(transferId) + if (target.isFile) return@runCatching true + val temporary = File(directory, ".${target.name}.tmp") + temporary.writeBytes(bytes) + temporary.renameTo(target).also { if (!it) temporary.delete() } + }.getOrDefault(false) + override fun delete(transferId: ULong) { file(transferId).delete() } + private fun file(transferId: ULong) = File(directory, "$transferId.preview") +} diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.jvm.kt new file mode 100644 index 0000000..11029f4 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.jvm.kt @@ -0,0 +1,62 @@ +package com.vnidrop.app.feature.send + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import java.awt.EventQueue +import java.awt.FileDialog +import java.awt.Frame +import java.awt.KeyboardFocusManager +import java.io.File + +@Composable +actual fun rememberTransferShareActions(): TransferShareActions = remember { + object : TransferShareActions { + override val canUseNativeShare = DesktopShareBridge.shareFile != null + override val nfcAvailability = NfcShareAvailability.Hidden + + override fun exportInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) { + EventQueue.invokeLater { + onResult(runCatching { + val dialog = FileDialog(activeFrame(), "Save VniDrop invitation", FileDialog.SAVE).apply { + file = invitationFileName(transferName) + } + try { + dialog.isVisible = true + val directory = dialog.directory + val name = dialog.file + if (directory != null && name != null) File(directory, name).writeText(ticket) + } finally { dialog.dispose() } + }) + } + } + + override fun shareInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) { + EventQueue.invokeLater { + val share = DesktopShareBridge.shareFile + if (share == null) { + onResult(Result.failure(UnsupportedOperationException("System sharing is unavailable on this desktop"))) + return@invokeLater + } + onResult(runCatching { + val directory = File(System.getProperty("java.io.tmpdir"), "vnidrop-share").apply { mkdirs() } + val file = File(directory, invitationFileName(transferName)).apply { writeText(ticket) } + share(file).getOrThrow() + }) + } + } + + override fun writeInvitationToNfc(ticket: String, onResult: (Result) -> Unit) { + onResult(Result.failure(UnsupportedOperationException("NFC is unavailable on desktop"))) + } + override fun cancelNfcWrite() = Unit + } +} + +private fun activeFrame(): Frame? = + (KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow as? Frame) + ?: Frame.getFrames().firstOrNull { it.isActive || it.isFocused } + +object DesktopShareBridge { + @Volatile + var shareFile: ((File) -> Result)? = null +} diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.jvm.kt new file mode 100644 index 0000000..a9fb196 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.jvm.kt @@ -0,0 +1,63 @@ +package com.vnidrop.app.notifications + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.awt.Color +import java.awt.Frame +import java.awt.Graphics2D +import java.awt.SystemTray +import java.awt.TrayIcon +import java.awt.image.BufferedImage + +class JvmLocalNotificationService : LocalNotificationService { + private val _permission = MutableStateFlow( + if (SystemTray.isSupported()) NotificationPermission.Granted else NotificationPermission.Unsupported, + ) + override val permission: StateFlow = _permission.asStateFlow() + private var trayIcon: TrayIcon? = null + + override suspend fun refreshPermission(): NotificationPermission = permission.value + override suspend fun requestPermission(): NotificationPermission = permission.value + override suspend fun openSettings(): Result = Result.failure( + UnsupportedOperationException("Notification settings are not available on this platform"), + ) + + override suspend fun publish(notification: LocalNotification): Result = runCatching { + check(permission.value == NotificationPermission.Granted) { "System notifications are not supported" } + val icon = trayIcon ?: createTrayIcon().also { + SystemTray.getSystemTray().add(it) + trayIcon = it + } + icon.displayMessage(notification.title, notification.body, TrayIcon.MessageType.INFO) + } + + override suspend fun cancel(id: String) = Unit + + override suspend fun cancelAll() { + trayIcon?.let { SystemTray.getSystemTray().remove(it) } + trayIcon = null + } + + private fun createTrayIcon(): TrayIcon { + val image = BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB) + val graphics = image.graphics as Graphics2D + try { + graphics.color = Color(83, 82, 237) + graphics.fillOval(2, 2, 28, 28) + graphics.color = Color.WHITE + graphics.fillRect(14, 8, 4, 16) + } finally { + graphics.dispose() + } + return TrayIcon(image, "VniDrop").apply { + isImageAutoSize = true + addActionListener { + Frame.getFrames().firstOrNull { it.isDisplayable }?.let { frame -> + frame.isVisible = true + frame.toFront() + } + } + } + } +} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStoreTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStoreTest.kt new file mode 100644 index 0000000..2dd75c1 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStoreTest.kt @@ -0,0 +1,26 @@ +package com.vnidrop.app.feature.send + +import kotlinx.coroutines.test.runTest +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertContentEquals + +class PlatformPreviewStoreTest { + @Test + fun preview_is_restored_from_private_application_storage() = runTest { + val directory = Files.createTempDirectory("vnidrop-preview-test") + try { + val bytes = ByteArray(16).also { + it[0] = 0x89.toByte(); it[1] = 'P'.code.toByte(); it[2] = 'N'.code.toByte(); it[3] = 'G'.code.toByte() + } + AppFilePreviewRepository(createPlatformPreviewStore(directory.toString())).save(91UL, bytes) + + val restarted = AppFilePreviewRepository(createPlatformPreviewStore(directory.toString())) + restarted.restore(setOf(91UL)) + + assertContentEquals(bytes, restarted.previews.value.getValue(91UL)) + } finally { + directory.toFile().deleteRecursively() + } + } +} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt new file mode 100644 index 0000000..87fa8d3 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt @@ -0,0 +1,61 @@ +package com.vnidrop.app.preferences + +import com.vnidrop.app.core.ReceiveFolder +import com.vnidrop.app.core.ReceiveFolderKind +import com.vnidrop.app.ui.theme.ThemeMode +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +class AppPreferencesRepositoryTest { + @Test + fun preferencesUseDefaultsWhenNothingIsStored() = runBlocking { + val repository = repositoryForTest() + + val preferences = repository.preferences.first() + + assertEquals("Device Name", preferences.username) + assertEquals(defaultFolder, preferences.receiveFolder) + assertEquals(ThemeMode.System, preferences.themeMode) + } + + @Test + fun themeModeIsPersisted() = runBlocking { + val repository = repositoryForTest() + + repository.setThemeMode(ThemeMode.Dark) + + assertEquals(ThemeMode.Dark, repository.preferences.first().themeMode) + } + + @Test + fun notificationOptInIsDisabledByDefaultAndPersisted() = runBlocking { + val repository = repositoryForTest() + + assertEquals(false, repository.preferences.first().notificationsEnabled) + repository.setNotificationsEnabled(true) + assertEquals(true, repository.preferences.first().notificationsEnabled) + } + + private fun repositoryForTest(): AppPreferencesRepository { + val directory = Files.createTempDirectory("vnidrop-preferences-test").toString() + return AppPreferencesRepository( + dataStore = createAppPreferencesDataStore(directory), + defaults = AppPreferencesDefaults( + username = "Device Name", + receiveFolder = defaultFolder, + themeMode = ThemeMode.System, + ), + ) + } + + private companion object { + val defaultFolder = ReceiveFolder( + kind = ReceiveFolderKind.FileSystemPath, + value = "/tmp/Downloads", + displayName = "Downloads", + ) + } +} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt new file mode 100644 index 0000000..58d1ea8 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -0,0 +1,364 @@ +package com.vnidrop.app.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.compose.runtime.mutableStateOf +import com.vnidrop.app.feature.approvals.ApprovalModalHost +import com.vnidrop.app.feature.approvals.ApprovalState +import com.vnidrop.app.feature.approvals.PendingApproval +import com.vnidrop.app.feature.settings.SettingsScreen +import com.vnidrop.app.feature.settings.SettingsSection +import com.vnidrop.app.feature.settings.SettingsState +import com.vnidrop.app.feature.send.SendScreen +import com.vnidrop.app.feature.send.SendState +import com.vnidrop.app.core.CoreState +import com.vnidrop.app.core.PickedShareFile +import com.vnidrop.app.core.ShareAccessPolicy +import com.vnidrop.app.core.Transfer +import com.vnidrop.app.core.TransferDirection +import com.vnidrop.app.core.TransferStatus +import com.vnidrop.app.notifications.NotificationPermission +import com.vnidrop.app.ui.feedback.UiMessage +import com.vnidrop.app.ui.feedback.UiMessageController +import com.vnidrop.app.ui.feedback.UiText +import com.vnidrop.app.ui.feedback.VniDropSnackbarHost +import com.vnidrop.app.ui.state.WindowClass +import com.vnidrop.app.ui.navigation.AppDestination +import com.vnidrop.app.ui.shell.AppShell +import com.vnidrop.app.ui.theme.VniDropTheme +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class) +class FoundationComposeTest { + @Test + fun approvalBannerInvokesAcceptAction() = runComposeUiTest { + var accepted: String? = null + setContent { + VniDropTheme(isDarkTheme = false) { + ApprovalModalHost( + state = ApprovalState(pending = listOf(approval())), + onAccept = { accepted = it }, + onRefuse = {}, + ) + } + } + onNodeWithText("Approve").performClick() + runOnIdle { assertEquals("request", accepted) } + } + + @Test + fun phoneSettingsNavigatesToNotificationSection() = runComposeUiTest { + val state = mutableStateOf(SettingsState()) + setContent { + VniDropTheme(isDarkTheme = false) { + SettingsScreen( + state = state.value, + windowClass = WindowClass.Phone, + onSectionSelected = { state.value = state.value.copy(selectedSection = it) }, + onUsernameChanged = {}, + onThemeModeChanged = {}, + onChooseFolder = {}, + onResetFolder = {}, + onNotificationsChanged = {}, + onOpenNotificationSettings = {}, + ) + } + } + onNodeWithText("Notifications").performClick() + onNodeWithText("Let VniDrop notify you about new connection requests while the app is running in the background.").assertIsDisplayed() + } + + @Test + fun notificationSettingCanBeToggledFromItsRow() = runComposeUiTest { + var enabled = false + setContent { + VniDropTheme(isDarkTheme = false) { + SettingsScreen( + state = SettingsState(selectedSection = SettingsSection.Notifications), + windowClass = WindowClass.Phone, + onSectionSelected = {}, + onUsernameChanged = {}, + onThemeModeChanged = {}, + onChooseFolder = {}, + onResetFolder = {}, + onNotificationsChanged = { enabled = it }, + onOpenNotificationSettings = {}, + ) + } + } + onNodeWithText("Allow notifications").performClick() + runOnIdle { assertEquals(true, enabled) } + } + + @Test + fun deniedNotificationSettingOffersSystemSettingsAction() = runComposeUiTest { + var opened = false + setContent { + VniDropTheme(isDarkTheme = false) { + SettingsScreen( + state = SettingsState( + selectedSection = SettingsSection.Notifications, + notificationPermission = NotificationPermission.Denied, + ), + windowClass = WindowClass.Phone, + onSectionSelected = {}, + onUsernameChanged = {}, + onThemeModeChanged = {}, + onChooseFolder = {}, + onResetFolder = {}, + onNotificationsChanged = {}, + onOpenNotificationSettings = { opened = true }, + ) + } + } + onNodeWithText("Open Settings").performClick() + runOnIdle { assertTrue(opened) } + } + + @Test + fun snackbarDisplaysBufferedMessage() = runComposeUiTest { + val controller = UiMessageController() + controller.tryShow(UiMessage(UiText.Dynamic("Saved successfully"))) + setContent { + VniDropTheme(isDarkTheme = false) { VniDropSnackbarHost(controller) } + } + onNodeWithText("Saved successfully").assertIsDisplayed() + onNodeWithContentDescription("Dismiss").assertIsDisplayed() + } + + @Test + fun compactSnackbarMovesActionBelowMessageAndClose() = runComposeUiTest { + val controller = UiMessageController() + controller.tryShow( + UiMessage( + text = UiText.Dynamic("Notifications are turned off for VniDrop. You can enable them in Settings."), + actionLabel = UiText.Dynamic("Open Settings"), + ), + ) + setContent { + VniDropTheme(isDarkTheme = false) { + Box(Modifier.width(320.dp)) { VniDropSnackbarHost(controller) } + } + } + + val messageBottom = onNodeWithText("Notifications are turned off for VniDrop. You can enable them in Settings.") + .getUnclippedBoundsInRoot().bottom + val closeBottom = onNodeWithContentDescription("Dismiss").getUnclippedBoundsInRoot().bottom + val actionTop = onNodeWithText("Open Settings").getUnclippedBoundsInRoot().top + assertTrue(messageBottom <= actionTop) + assertTrue(closeBottom <= actionTop) + } + + @Test + fun phoneSnackbarOverlayStopsAboveBottomNavigation() = runComposeUiTest { + setContent { + VniDropTheme(isDarkTheme = false) { + AppShell( + selectedDestination = AppDestination.Send, + windowClass = WindowClass.Phone, + onDestinationSelected = {}, + overlay = { + Box(Modifier.align(Alignment.BottomCenter).size(20.dp).testTag("snackbar-overlay")) + }, + floatingAction = { + Box(Modifier.align(Alignment.BottomEnd).size(56.dp).testTag("floating-action")) + }, + ) { + Text("Content") + } + } + } + + val overlayBottom = onNodeWithTag("snackbar-overlay").getUnclippedBoundsInRoot().bottom + val floatingActionTop = onNodeWithTag("floating-action").getUnclippedBoundsInRoot().top + val navigationLabelTop = onNodeWithText("Send").getUnclippedBoundsInRoot().top + assertTrue(overlayBottom <= floatingActionTop) + assertTrue(overlayBottom <= navigationLabelTop) + } + + @Test + fun phoneSendEmptyStateOpensCreationDrawer() = runComposeUiTest { + val state = mutableStateOf(SendState()) + setContent { + VniDropTheme(isDarkTheme = false) { + SendScreen( + coreState = CoreState(isInitialized = true), + state = state.value, + windowClass = WindowClass.Phone, + onOpenComposer = { state.value = state.value.copy(isComposerOpen = true) }, + onDismissComposer = {}, + onSelectFile = {}, + onClearFile = {}, + onTransferNameChanged = {}, + onSenderNameChanged = {}, + onAccessPolicyChanged = {}, + onCreateShare = {}, + onTransferSelected = {}, + onCloseTransferDetails = {}, + onCopyTicket = {}, + ) + } + } + + onNodeWithText("New transfer").performClick() + onNodeWithText("Choose what to share").assertIsDisplayed() + onNodeWithText("Choose file").assertIsDisplayed() + } + + @Test + fun desktopTransferComposerReviewsFileAndAccessPolicy() = runComposeUiTest { + var selectedPolicy: ShareAccessPolicy? = null + setContent { + VniDropTheme(isDarkTheme = false) { + SendScreen( + coreState = CoreState(isInitialized = true), + state = SendState( + isComposerOpen = true, + selectedFile = PickedShareFile("/tmp/photos.zip", "photos.zip", 1536UL), + transferName = "photos.zip", + senderName = "Sender", + ), + windowClass = WindowClass.Desktop, + onOpenComposer = {}, + onDismissComposer = {}, + onSelectFile = {}, + onClearFile = {}, + onTransferNameChanged = {}, + onSenderNameChanged = {}, + onAccessPolicyChanged = { selectedPolicy = it }, + onCreateShare = {}, + onTransferSelected = {}, + onCloseTransferDetails = {}, + onCopyTicket = {}, + ) + } + } + + onNodeWithText("1.5 KB").assertIsDisplayed() + onNodeWithText("Anyone with this transfer").performClick() + runOnIdle { assertEquals(ShareAccessPolicy.AnyoneWithTransfer, selectedPolicy) } + } + + @Test + fun transferCatalogOpensSelectedTransfer() = runComposeUiTest { + var selectedId: ULong? = null + setContent { + VniDropTheme(isDarkTheme = false) { + SendScreen( + coreState = CoreState(isInitialized = true, transfers = listOf(outgoingTransfer())), + state = SendState(), + windowClass = WindowClass.Phone, + onOpenComposer = {}, + onDismissComposer = {}, + onSelectFile = {}, + onClearFile = {}, + onTransferNameChanged = {}, + onSenderNameChanged = {}, + onAccessPolicyChanged = {}, + onCreateShare = {}, + onTransferSelected = { selectedId = it }, + onCloseTransferDetails = {}, + onCopyTicket = {}, + ) + } + } + + val titleBounds = onNodeWithText("Photos").getUnclippedBoundsInRoot() + val statusBounds = onNodeWithText("Available").getUnclippedBoundsInRoot() + assertTrue(statusBounds.left - titleBounds.right <= 12.dp) + onNodeWithText("Photos").performClick() + runOnIdle { assertEquals(9UL, selectedId) } + } + + @Test + fun transferDetailsRevealSharingOnlyAfterSelection() = runComposeUiTest { + val state = mutableStateOf(SendState(selectedTransferId = 9UL)) + setContent { + VniDropTheme(isDarkTheme = false) { + SendScreen( + coreState = CoreState(isInitialized = true, transfers = listOf(outgoingTransfer())), + state = state.value, + windowClass = WindowClass.Desktop, + onOpenComposer = {}, onDismissComposer = {}, onSelectFile = {}, onClearFile = {}, + onTransferNameChanged = {}, onSenderNameChanged = {}, onAccessPolicyChanged = {}, + onCreateShare = {}, onTransferSelected = {}, onCloseTransferDetails = {}, onCopyTicket = {}, + onShare = { state.value = state.value.copy(detailPanel = com.vnidrop.app.feature.send.TransferDetailPanel.Share) }, + ) + } + } + + onNodeWithText("Share").assertIsDisplayed() + onAllNodesWithText("Scan with VniDrop to receive this transfer").assertCountEquals(0) + onNode(hasText("Share") and hasClickAction()).performClick() + runOnIdle { assertEquals(com.vnidrop.app.feature.send.TransferDetailPanel.Share, state.value.detailPanel) } + onNodeWithText("Scan with VniDrop to receive this transfer").assertIsDisplayed() + onNodeWithText("Save .vnd file").assertIsDisplayed() + onNodeWithContentDescription("Close").assertIsDisplayed() + } + + @Test + fun snackbarActionAndCancellationAreForwarded() = runComposeUiTest { + val controller = UiMessageController() + var actionCount = 0 + controller.tryShow( + UiMessage( + text = UiText.Dynamic("Undoable action"), + actionLabel = UiText.Dynamic("Undo"), + onAction = { actionCount += 1 }, + ), + ) + setContent { VniDropTheme(isDarkTheme = false) { VniDropSnackbarHost(controller) } } + onNodeWithText("Undo").performClick() + runOnIdle { assertEquals(1, actionCount) } + + controller.tryShow(UiMessage(UiText.Dynamic("Dismiss me"))) + onNodeWithText("Dismiss me").assertIsDisplayed() + controller.dismissCurrent() + onAllNodesWithText("Dismiss me").assertCountEquals(0) + } + + private fun approval() = PendingApproval( + id = "request", + transferId = 1UL, + transferName = "Photos", + receiverName = "Alice", + receiverDeviceName = "Phone", + requestedAt = 1L, + ) + + private fun outgoingTransfer() = Transfer( + localId = "local-9", + transferId = 9UL, + direction = TransferDirection.Send, + status = TransferStatus.Sharing, + peerId = null, + transferName = "Photos", + contentHash = "hash", + fileCount = 1UL, + totalSize = 1536UL, + ticket = "ticket", + accessPolicy = ShareAccessPolicy.RequireApproval, + createdAt = 1L, + updatedAt = 1L, + ) +}