diff --git a/crates/vnidrop/src/runtime.rs b/crates/vnidrop/src/runtime.rs index 9a8b75b..4d1ce4e 100644 --- a/crates/vnidrop/src/runtime.rs +++ b/crates/vnidrop/src/runtime.rs @@ -1,5 +1,6 @@ use std::{ collections::HashMap, + future::Future, io, path::{Path, PathBuf}, str::FromStr, @@ -59,6 +60,19 @@ pub struct VnidropCore { inner: Arc, } +impl VnidropCore { + /// Drive work on this core's multi-thread runtime from a sync API boundary. + /// + /// Uses [`tokio::runtime::Handle::block_on`] rather than exclusive + /// [`Runtime::block_on`] so a concurrent call (for example cancel while + /// another thread is blocked inside `receive`) cannot deadlock the runtime + /// driver. UniFFI and tests both rely on that: receive runs on a worker + /// thread while cancel/approve arrive from the UI or test harness thread. + fn block_on(&self, future: F) -> F::Output { + self.runtime.handle().block_on(future) + } +} + struct CoreInner { // Kotlin owns app lifecycle and platform file picking; this Rust object // owns the Iroh endpoint, blob store, transfer history, and byte streaming. @@ -71,7 +85,9 @@ struct CoreInner { limits: CoreLimits, transfer_slots: Semaphore, access_policy: Arc, - active_transfers: TokioMutex>, + /// Sync mutex so cancel can remove + signal without awaiting (and without + /// holding a Tokio lock across repository I/O). + active_transfers: std::sync::Mutex>, // 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. @@ -173,7 +189,7 @@ impl VnidropCore { } pub fn status(&self) -> RuntimeStatus { - self.runtime.block_on(self.inner.status()) + self.block_on(self.inner.status()) } pub fn share_files( @@ -181,8 +197,7 @@ impl VnidropCore { sources: Vec, metadata: ShareMetadataInput, ) -> Result { - self.runtime - .block_on(self.inner.share_files(sources, metadata)) + self.block_on(self.inner.share_files(sources, metadata)) .map_err(VnidropError::transfer) } @@ -195,7 +210,7 @@ impl VnidropCore { 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.block_on(async { self.inner.emit_endpoint( "error", "invalid-ticket", @@ -206,8 +221,7 @@ impl VnidropCore { return Err(VnidropError::ticket(error)); } let output_dir = platform_path(&output_dir).map_err(VnidropError::filesystem)?; - self.runtime - .block_on(self.inner.receive(ticket, output_dir, receiver_name)) + self.block_on(self.inner.receive(ticket, output_dir, receiver_name)) .map_err(VnidropError::transfer) } @@ -220,7 +234,7 @@ impl VnidropCore { 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.block_on(async { self.inner.emit_endpoint( "error", "invalid-ticket", @@ -230,29 +244,54 @@ impl VnidropCore { }); return Err(VnidropError::ticket(error)); } - self.runtime - .block_on( - self.inner - .receive_with_output_sink(ticket, output_sink, receiver_name), - ) - .map_err(VnidropError::transfer) + self.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)) + // Fire the oneshot on this thread before entering the runtime so a + // blocked export write cannot prevent the cancel signal from being + // delivered (the receive `select!` observes it on the next yield). + if let Some(direction) = self.inner.take_active_transfer(transfer_id) { + let expected = match direction { + TransferDirection::Send => TransferStatus::Importing, + TransferDirection::Receive => TransferStatus::Receiving, + }; + return self + .block_on(async { + self.inner + .repository + .transition_transfer_status( + transfer_id, + expected, + TransferStatus::Cancelled, + ) + .await?; + self.inner.emit_transfer( + transfer_id, + direction.as_str(), + "lifecycle", + "cancel-requested", + json!({}), + ); + Ok::<(), anyhow::Error>(()) + }) + .map_err(VnidropError::transfer); + } + self.block_on(self.inner.cancel_idle_or_share(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)) + self.block_on(self.inner.delete_transfer(transfer_id)) .map_err(VnidropError::transfer) } pub fn delete_receive_history(&self) -> Result { - self.runtime - .block_on(self.inner.delete_receive_history()) + self.block_on(self.inner.delete_receive_history()) .map_err(VnidropError::repository) } @@ -261,8 +300,7 @@ impl VnidropCore { transfer_id: u64, mode: TransferAccessMode, ) -> Result<(), VnidropError> { - self.runtime - .block_on(self.inner.set_transfer_access_mode(transfer_id, mode)) + self.block_on(self.inner.set_transfer_access_mode(transfer_id, mode)) .map_err(VnidropError::permission) } @@ -271,20 +309,18 @@ impl VnidropCore { transfer_id: u64, endpoint_id: String, ) -> Result<(), VnidropError> { - self.runtime - .block_on( - self.inner - .approve_endpoint_for_transfer(transfer_id, endpoint_id), - ) - .map_err(VnidropError::permission) + self.block_on( + self.inner + .approve_endpoint_for_transfer(transfer_id, endpoint_id), + ) + .map_err(VnidropError::permission) } pub fn list_receiver_requests( &self, transfer_id: u64, ) -> Result, VnidropError> { - self.runtime - .block_on(self.inner.repository.list_receiver_requests(transfer_id)) + self.block_on(self.inner.repository.list_receiver_requests(transfer_id)) .map_err(VnidropError::repository) } @@ -294,20 +330,17 @@ impl VnidropCore { accepted: bool, reason: Option, ) -> Result<(), VnidropError> { - self.runtime - .block_on(self.inner.approval.respond(request_id, accepted, reason)) + self.block_on(self.inner.approval.respond(request_id, accepted, reason)) .map_err(VnidropError::permission) } pub fn list_transfers(&self) -> Result, VnidropError> { - self.runtime - .block_on(self.inner.repository.list_transfers()) + self.block_on(self.inner.repository.list_transfers()) .map_err(VnidropError::repository) } pub fn list_events(&self, transfer_id: Option) -> Result, VnidropError> { - self.runtime - .block_on(self.inner.list_events(transfer_id)) + self.block_on(self.inner.list_events(transfer_id)) .map_err(VnidropError::repository) } @@ -327,7 +360,7 @@ impl VnidropCore { } pub fn shutdown(&self) { - self.runtime.block_on(self.inner.shutdown()); + self.block_on(self.inner.shutdown()); } } @@ -439,7 +472,7 @@ impl CoreInner { transfer_slots: Semaphore::new(limits.max_concurrent_transfers as usize), limits, access_policy, - active_transfers: TokioMutex::new(HashMap::new()), + active_transfers: std::sync::Mutex::new(HashMap::new()), active_shares: TokioMutex::new(restored_active_shares), hash_to_transfer: TokioMutex::new(restored_hashes), connection_endpoints: TokioMutex::new(HashMap::new()), @@ -461,11 +494,17 @@ impl CoreInner { } async fn status(&self) -> RuntimeStatus { + let active_transfers = self + .active_transfers + .lock() + .expect("active_transfers") + .len() as u64; + let active_shares = self.active_shares.lock().await.len() as u64; RuntimeStatus { endpoint_id: self.endpoint.id().to_string(), addr: format!("{:?}", self.endpoint.addr()), - active_transfers: self.active_transfers.lock().await.len() as u64, - active_shares: self.active_shares.lock().await.len() as u64, + active_transfers, + active_shares, } } @@ -509,18 +548,24 @@ impl CoreInner { }) .await?; let (cancel, mut cancelled) = oneshot::channel(); - self.active_transfers.lock().await.insert( - transfer_id, - ActiveTransfer { - direction: TransferDirection::Send, - cancel, - }, - ); + self.active_transfers + .lock() + .expect("active_transfers") + .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); + self.active_transfers + .lock() + .expect("active_transfers") + .remove(&transfer_id); if let Err(error) = &result { if was_cancelled { self.emit_transfer(transfer_id, "send", "lifecycle", "cancelled", json!({})); @@ -689,20 +734,26 @@ impl CoreInner { // 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, - ActiveTransfer { - direction: TransferDirection::Receive, - cancel: shutdown_tx, - }, - ); + self.active_transfers + .lock() + .expect("active_transfers") + .insert( + transfer_id, + ActiveTransfer { + direction: TransferDirection::Receive, + cancel: shutdown_tx, + }, + ); 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); + self.active_transfers + .lock() + .expect("active_transfers") + .remove(&transfer_id); if let Err(error) = &result { if cancelled { self.emit_transfer(transfer_id, "receive", "lifecycle", "cancelled", json!({})); @@ -898,35 +949,20 @@ impl CoreInner { Ok(()) } - async fn cancel_transfer(&self, transfer_id: u64) -> Result<()> { - 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, - direction.as_str(), - "lifecycle", - "cancel-requested", - json!({}), - ); - return Ok(()); - } - drop(active_transfers); + /// Remove an in-flight transfer and fire its cancel oneshot synchronously. + fn take_active_transfer(&self, transfer_id: u64) -> Option { + let active = self + .active_transfers + .lock() + .expect("active_transfers") + .remove(&transfer_id)?; + let direction = active.direction; + let _ = active.cancel.send(()); + Some(direction) + } + /// Stop a live share or report missing when no active transfer was found. + async fn cancel_idle_or_share(&self, transfer_id: u64) -> Result<()> { let mut active_shares = self.active_shares.lock().await; if active_shares.contains_key(&transfer_id) { self.repository @@ -953,7 +989,7 @@ impl CoreInner { if self .active_transfers .lock() - .await + .expect("active_transfers") .contains_key(&transfer_id) { anyhow::bail!("an active transfer must finish or be cancelled before deletion"); @@ -1389,6 +1425,10 @@ impl CoreInner { "exported": exported, }), ); + // Yield so outer `select!` cancel can land between chunks. + // Sink writes are synchronous (foreign FFI); without this, + // a single poll can drain the stream and miss cancellation. + tokio::task::yield_now().await; } ExportRangesItem::Error(error) => { anyhow::bail!("export failed for {relative_path}: {error}"); @@ -1485,7 +1525,8 @@ impl CoreInner { message.inner.connection_id, message.inner.request_id, message.rx, - ); + ) + .await; } let _ = message.tx.send(Ok(())).await; } @@ -1497,7 +1538,8 @@ impl CoreInner { message.inner.connection_id, message.inner.request_id, message.rx, - ); + ) + .await; } } ProviderMessage::GetManyRequestReceived(message) => { @@ -1531,7 +1573,8 @@ impl CoreInner { message.inner.connection_id, message.inner.request_id, message.rx, - ); + ) + .await; } let _ = message.tx.send(Ok(())).await; } @@ -1545,7 +1588,8 @@ impl CoreInner { message.inner.connection_id, message.inner.request_id, message.rx, - ); + ) + .await; } } ProviderMessage::ObserveRequestReceived(message) => { @@ -1618,7 +1662,7 @@ impl CoreInner { .await } - fn track_request_updates( + async fn track_request_updates( self: &Arc, transfer_id: u64, connection_id: u64, @@ -1628,6 +1672,15 @@ impl CoreInner { // Request update tasks are tied to individual provider streams. Router // shutdown closes those streams; only the long-lived provider receiver // is tracked directly for explicit shutdown. + // + // Attach the remote endpoint id when known so the send UI can attribute + // byte progress to a specific receiver (not just an opaque connection). + let endpoint_id = self + .connection_endpoints + .lock() + .await + .get(&connection_id) + .cloned(); let core = self.clone(); tokio::spawn(async move { while let Ok(Some(update)) = rx.recv().await { @@ -1640,6 +1693,7 @@ impl CoreInner { json!({ "connection_id": connection_id, "request_id": request_id, + "endpoint_id": endpoint_id, "hash": started.hash.to_string(), "size": started.size, "index": started.index, @@ -1653,6 +1707,7 @@ impl CoreInner { json!({ "connection_id": connection_id, "request_id": request_id, + "endpoint_id": endpoint_id, "end_offset": progress.end_offset, }), ), @@ -1661,14 +1716,22 @@ impl CoreInner { "send", "transfer", "completed", - json!({ "connection_id": connection_id, "request_id": request_id }), + json!({ + "connection_id": connection_id, + "request_id": request_id, + "endpoint_id": endpoint_id, + }), ), RequestUpdate::Aborted(_) => core.emit_transfer( transfer_id, "send", "transfer", "aborted", - json!({ "connection_id": connection_id, "request_id": request_id }), + json!({ + "connection_id": connection_id, + "request_id": request_id, + "endpoint_id": endpoint_id, + }), ), } } diff --git a/crates/vnidrop/tests/output_sink.rs b/crates/vnidrop/tests/output_sink.rs index e8512d8..1b01319 100644 --- a/crates/vnidrop/tests/output_sink.rs +++ b/crates/vnidrop/tests/output_sink.rs @@ -68,21 +68,23 @@ fn reports_output_sink_write_failure() { #[test] fn cancellation_during_export_aborts_open_sink_file() { + // Gate the first write so export is mid-file when cancel runs. A large + // "slow writes" file made this flaky/hang-prone on CI: concurrent cancel + // used to nest Runtime::block_on, and multi-megabyte sleep loops could + // starve the cancel path for a long time. 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 source_path = source_dir.path().join("gated.bin"); + std::fs::write(&source_path, vec![0u8; 256 * 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 share = share_path(&sender.core, &source_path, 29, "gated.bin", false); + let output_sink = Arc::new(MemoryOutputSink::block_first_write()); let receiver_core = receiver.core.arc(); let sink_for_worker = output_sink.clone(); + let ticket = share.ticket.clone(); let worker = std::thread::spawn(move || { receiver_core.receive_with_output_sink( - share.ticket, + ticket, sink_for_worker, Some("receiver".to_string()), ) @@ -94,11 +96,35 @@ fn cancellation_during_export_aborts_open_sink_file() { .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)); + while !output_sink.has_started("gated.bin") || !output_sink.has_entered_write() { + assert!( + started.elapsed() < Duration::from_secs(15), + "export never opened gated.bin for writing" + ); std::thread::sleep(Duration::from_millis(10)); } + // First write is parked in the sink. Cancel must complete from this thread + // without deadlocking against the receive worker's runtime driver. receiver.core.cancel_transfer(share.transfer_id).unwrap(); - assert!(worker.join().unwrap().is_err()); - assert_eq!(output_sink.terminal_state("slow.bin"), Some("aborted")); + // Unblock the in-flight write so the receive future can yield, observe + // cancel, and Drop(OutputSinkFile) can abort the open file. + output_sink.release_write_gate(); + + let result = worker + .join() + .expect("receive worker thread panicked") + .expect_err("cancelled receive should return an error"); + let message = result.to_string(); + assert!( + message.contains("cancel") || message.contains("interrupted") || !message.is_empty(), + "unexpected cancel error: {message}" + ); + let abort_deadline = Instant::now(); + while output_sink.terminal_state("gated.bin") != Some("aborted") { + assert!( + abort_deadline.elapsed() < Duration::from_secs(5), + "open sink file was not aborted after cancel" + ); + std::thread::sleep(Duration::from_millis(10)); + } } diff --git a/crates/vnidrop/tests/support/mod.rs b/crates/vnidrop/tests/support/mod.rs index 6989e9b..a820e50 100644 --- a/crates/vnidrop/tests/support/mod.rs +++ b/crates/vnidrop/tests/support/mod.rs @@ -6,7 +6,10 @@ use std::{ collections::HashMap, ops::Deref, path::Path, - sync::{Arc, Mutex}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Condvar, Mutex, + }, time::{Duration, Instant}, }; @@ -97,6 +100,9 @@ pub struct MemoryOutputSink { terminal: Mutex>, fail_writes: bool, write_delay: Duration, + /// When set, every write waits until [Self::release_write_gate] opens the gate. + write_gate: Option, Condvar)>>, + write_entered: AtomicBool, } impl MemoryOutputSink { @@ -106,6 +112,8 @@ impl MemoryOutputSink { terminal: Mutex::new(HashMap::new()), fail_writes: true, write_delay: Duration::ZERO, + write_gate: None, + write_entered: AtomicBool::new(false), } } @@ -115,6 +123,32 @@ impl MemoryOutputSink { terminal: Mutex::new(HashMap::new()), fail_writes: false, write_delay: delay, + write_gate: None, + write_entered: AtomicBool::new(false), + } + } + + /// Park every `write_chunk` until [Self::release_write_gate] is called. + /// + /// Used to hold export mid-file so cancel can run from another thread + /// without racing a multi-megabyte slow-write loop. + pub fn block_first_write() -> Self { + Self { + files: Mutex::new(HashMap::new()), + terminal: Mutex::new(HashMap::new()), + fail_writes: false, + write_delay: Duration::ZERO, + write_gate: Some(Arc::new((Mutex::new(false), Condvar::new()))), + write_entered: AtomicBool::new(false), + } + } + + pub fn release_write_gate(&self) { + if let Some(gate) = &self.write_gate { + let (lock, cvar) = gate.as_ref(); + let mut open = lock.lock().unwrap(); + *open = true; + cvar.notify_all(); } } @@ -129,6 +163,10 @@ impl MemoryOutputSink { pub fn has_started(&self, relative_path: &str) -> bool { self.files.lock().unwrap().contains_key(relative_path) } + + pub fn has_entered_write(&self) -> bool { + self.write_entered.load(Ordering::SeqCst) + } } impl ReceiveOutputSink for MemoryOutputSink { @@ -138,6 +176,14 @@ impl ReceiveOutputSink for MemoryOutputSink { } fn write_chunk(&self, relative_path: String, bytes: Vec) -> Result<(), VnidropError> { + self.write_entered.store(true, Ordering::SeqCst); + if let Some(gate) = &self.write_gate { + let (lock, cvar) = gate.as_ref(); + let mut open = lock.lock().unwrap(); + while !*open { + open = cvar.wait(open).unwrap(); + } + } if !self.write_delay.is_zero() { std::thread::sleep(self.write_delay); } diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 3799332..02dd757 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -296,6 +296,7 @@ ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = iosApp/vnidrop.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; @@ -303,6 +304,8 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_NFCReaderUsageDescription = "VniDrop uses NFC to read transfer invitation tags."; + INFOPLIST_KEY_NSCameraUsageDescription = "VniDrop uses the camera to scan transfer QR codes."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -318,6 +321,10 @@ SystemConfiguration, "-framework", Network, + "-framework", + CoreNFC, + "-framework", + AVFoundation, ); SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -331,6 +338,7 @@ ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = iosApp/vnidrop.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; @@ -338,6 +346,8 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_NFCReaderUsageDescription = "VniDrop uses NFC to read transfer invitation tags."; + INFOPLIST_KEY_NSCameraUsageDescription = "VniDrop uses the camera to scan transfer QR codes."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -353,6 +363,10 @@ SystemConfiguration, "-framework", Network, + "-framework", + CoreNFC, + "-framework", + AVFoundation, ); SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 1bc96a2..5592df5 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -43,6 +43,10 @@ LSSupportsOpeningDocumentsInPlace + NSCameraUsageDescription + VniDrop uses the camera to scan transfer QR codes. + NFCReaderUsageDescription + VniDrop uses NFC to read transfer invitation tags. UIViewControllerBasedStatusBarAppearance diff --git a/iosApp/iosApp/vnidrop.entitlements b/iosApp/iosApp/vnidrop.entitlements new file mode 100644 index 0000000..5f7b942 --- /dev/null +++ b/iosApp/iosApp/vnidrop.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.nfc.readersession.formats + + NDEF + + + 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 5b5cf8c..927219b 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 @@ -20,7 +20,7 @@ actual fun rememberShareFilePicker( onError: (String) -> Unit, ): ShareFilePicker { val context = LocalContext.current - val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> + val filesLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> if (uris.isEmpty()) return@rememberLauncherForActivityResult runCatching { uris.map { uri -> context.pickedShareFile(uri) } @@ -29,10 +29,41 @@ actual fun rememberShareFilePicker( onFailure = { onError(it.message ?: "Could not open the selected files") }, ) } - return remember(launcher) { + val folderLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + runCatching { + // Read permission only — we expand the tree into file FDs at share time. + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + val displayName = uri.lastPathSegment + ?.substringAfterLast(':') + ?.substringAfterLast('/') + ?.ifBlank { null } + ?: "Folder" + listOf( + PickedShareFile( + value = uri.toString(), + displayName = displayName, + sizeBytes = null, + thumbnailBytes = null, + isDirectory = true, + ), + ) + }.fold( + onSuccess = onFilesPicked, + onFailure = { onError(it.message ?: "Could not open the selected folder") }, + ) + } + return remember(filesLauncher, folderLauncher) { object : ShareFilePicker { override fun pickFiles() { - launcher.launch(arrayOf("*/*")) + filesLauncher.launch(arrayOf("*/*")) + } + + override fun pickFolder() { + folderLauncher.launch(null) } } } 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 index 2873a05..c986f21 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt @@ -71,12 +71,18 @@ private class AndroidFileSystemService( accessPolicy: ShareAccessPolicy, ): Result = runCatching { require(files.isNotEmpty()) { "Select at least one file to share" } - val descriptors = files.map { file -> + // Android cannot pass a directory as a single FD. Expand SAF trees into + // individual document files with relative collection paths, then open FDs. + val expanded = files.flatMap { file -> + if (file.isDirectory) context.expandShareDirectory(file) else listOf(file) + } + require(expanded.isNotEmpty()) { "No files found in the selected folder" } + val descriptors = expanded.map { file -> context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r") ?: error("Could not open selected file descriptor for ${file.displayName}") } try { - val sources = files.zip(descriptors) { file, descriptor -> + val sources = expanded.zip(descriptors) { file, descriptor -> uniffi.vnidrop.ShareSource( kind = uniffi.vnidrop.SourceKind.FILE_DESCRIPTOR, value = descriptor.fd.toString(), @@ -140,6 +146,63 @@ private class AndroidFileSystemService( } } +/** + * Expand a SAF document tree into individual file documents. + * + * Rust cannot accept a directory FD. Collection paths preserve the folder + * root name so receivers see `Folder/nested/file.txt`. + */ +private fun Context.expandShareDirectory(folder: PickedShareFile): List { + val treeUri = Uri.parse(folder.value) + val rootId = DocumentsContract.getTreeDocumentId(treeUri) + val out = mutableListOf() + fun walk(documentId: String, relativePath: String) { + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, documentId) + contentResolver.query( + childrenUri, + arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_SIZE, + ), + null, + null, + null, + )?.use { cursor -> + val idIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + val nameIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + val mimeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE) + val sizeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE) + while (cursor.moveToNext()) { + val id = cursor.getString(idIndex) ?: continue + val name = cursor.getString(nameIndex) ?: continue + val mime = cursor.getString(mimeIndex) + val childRelative = if (relativePath.isEmpty()) name else "$relativePath/$name" + if (mime == DocumentsContract.Document.MIME_TYPE_DIR) { + walk(id, childRelative) + } else { + val documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, id) + val size = if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) { + cursor.getLong(sizeIndex).takeIf { it >= 0L }?.toULong() + } else { + null + } + out += PickedShareFile( + value = documentUri.toString(), + displayName = childRelative, + sizeBytes = size, + isDirectory = false, + ) + } + } + } + } + // Prefix paths with the folder display name so nested structure is preserved. + walk(rootId, folder.displayName) + return out +} + /** * Writes into the shared system Downloads collection via MediaStore. * diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 044cebc..28749d3 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -10,8 +10,9 @@ Your transfers New transfer Choose what to share - Select one or more files from this device. You can review them before creating the transfer. + Select files or a folder from this device. You can review the selection before creating the transfer. %1$d files selected + Folder Remove file Choose files Change files @@ -122,6 +123,7 @@ Preparing transfer %1$d waiting %1$d completed + Sending Downloading files Saving files Connecting to sender 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 b1f66bb..7b29acb 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt @@ -7,11 +7,20 @@ data class PickedShareFile( val displayName: String, val sizeBytes: ULong? = null, val thumbnailBytes: ByteArray? = null, + /** + * When true, [value] is a directory (filesystem path, iOS security-scoped + * folder URL, or Android document tree URI). Platform share code expands or + * walks it; Rust cannot treat an Android FD as a directory. + */ + val isDirectory: Boolean = false, ) interface ShareFilePicker { /** Opens a platform picker that may return one or more files. */ fun pickFiles() + + /** Opens a platform folder picker for sharing a directory as one transfer. */ + fun pickFolder() } interface ReceiveFolderPicker { 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 index c982b25..dd419e3 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt @@ -41,7 +41,9 @@ 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.StatusPill +import com.vnidrop.app.ui.state.TransferProgress import com.vnidrop.app.ui.state.WindowClass +import com.vnidrop.app.ui.state.activeSendProgress import com.vnidrop.app.ui.state.displayNameForStatus import com.vnidrop.app.ui.state.formatBytes import com.vnidrop.app.ui.state.progressForTransfer @@ -101,10 +103,15 @@ internal fun TransferCatalog( ) } items(transfers, key = Transfer::localId) { transfer -> + val progress = when (transfer.status) { + TransferStatus.Importing -> progressForTransfer(events, transfer.transferId) + TransferStatus.Sharing -> activeSendProgress(events, transfer.transferId, transfer.totalSize) + else -> null + } TransferListItem( transfer = transfer, thumbnailBytes = transferThumbnails[transfer.transferId], - progress = progressForTransfer(events, transfer.transferId), + progress = progress, onClick = { onTransferSelected(transfer.transferId) }, ) } @@ -170,7 +177,7 @@ private fun SendEmptyState(onOpenComposer: () -> Unit) { private fun TransferListItem( transfer: Transfer, thumbnailBytes: ByteArray?, - progress: com.vnidrop.app.ui.state.TransferProgress?, + progress: TransferProgress?, onClick: () -> Unit, ) { val colors = LocalVniDropColors.current @@ -203,7 +210,7 @@ private fun TransferListItem( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - if (transfer.status == TransferStatus.Importing && progress != null) { + if (progress != null && transfer.status in setOf(TransferStatus.Importing, TransferStatus.Sharing)) { ProgressRow(label = progress.label, progress = progress.progress, detail = progress.detail) } } 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 index 5e9375e..f8d2cab 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt @@ -24,6 +24,7 @@ fun SendRoute( viewModel.effectFlow.collect { effect -> when (effect) { SendEffect.OpenFilePicker -> picker.pickFiles() + SendEffect.OpenFolderPicker -> picker.pickFolder() is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket)) } } @@ -37,6 +38,7 @@ fun SendRoute( onOpenComposer = viewModel::openComposer, onDismissComposer = viewModel::dismissComposer, onSelectFile = viewModel::selectFile, + onSelectFolder = viewModel::selectFolder, onClearFile = viewModel::clearSelectedSource, onRemoveFile = viewModel::removeSelectedFile, onTransferNameChanged = viewModel::setTransferName, 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 index 81aa8f2..2cade4b 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt @@ -24,6 +24,7 @@ fun SendScreen( onOpenComposer: () -> Unit, onDismissComposer: () -> Unit, onSelectFile: () -> Unit, + onSelectFolder: () -> Unit = {}, onClearFile: () -> Unit, onRemoveFile: (String) -> Unit = {}, onTransferNameChanged: (String) -> Unit, @@ -83,6 +84,7 @@ fun SendScreen( state = state, windowClass = windowClass, onSelectFile = onSelectFile, + onSelectFolder = onSelectFolder, onClearFile = onClearFile, onRemoveFile = onRemoveFile, onTransferNameChanged = onTransferNameChanged, @@ -97,7 +99,12 @@ fun SendScreen( AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onCloseDetailPanel) { when (state.detailPanel) { TransferDetailPanel.Activity -> TransferActivityPanel(coreState.events, selectedTransfer.transferId) - TransferDetailPanel.Receivers -> ReceiverHistoryPanel(state.receiverHistory, state.isLoadingReceivers) + TransferDetailPanel.Receivers -> ReceiverHistoryPanel( + receivers = state.receiverHistory, + loading = state.isLoadingReceivers, + events = coreState.events, + transferTotalSize = selectedTransfer.totalSize, + ) TransferDetailPanel.Share -> TransferSharePanel( selectedTransfer, shareActions, 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 index c608975..2918f59 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt @@ -54,6 +54,7 @@ enum class TransferDetailPanel { Activity, Receivers, Share } sealed interface SendEffect { data object OpenFilePicker : SendEffect + data object OpenFolderPicker : SendEffect data class CopyTicket(val ticket: String) : SendEffect } @@ -135,6 +136,7 @@ class SendViewModel( } fun selectFile() = sendEffect(SendEffect.OpenFilePicker) + fun selectFolder() = sendEffect(SendEffect.OpenFolderPicker) fun onFilesPicked(files: List) { if (files.isEmpty()) return @@ -271,9 +273,11 @@ class SendViewModel( } } - private fun defaultTransferName(files: List): String = when (files.size) { - 0 -> "" - 1 -> files.first().displayName + private fun defaultTransferName(files: List): String = when { + files.isEmpty() -> "" + files.size == 1 && files.first().isDirectory -> files.first().displayName + files.size == 1 -> files.first().displayName + files.all { it.isDirectory } -> "${files.size} folders" else -> "${files.size} files" } 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 index fa8cee5..c2af833 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt @@ -42,6 +42,7 @@ import org.jetbrains.compose.resources.stringResource import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.button_change_files import vnidrop.shared.generated.resources.button_choose_files +import vnidrop.shared.generated.resources.button_choose_folder import vnidrop.shared.generated.resources.button_clear import vnidrop.shared.generated.resources.button_remove_file import vnidrop.shared.generated.resources.button_share_file @@ -56,6 +57,7 @@ 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_folder_label import vnidrop.shared.generated.resources.send_review_title import vnidrop.shared.generated.resources.send_selected_files_count @@ -65,6 +67,7 @@ internal fun TransferComposer( state: SendState, windowClass: WindowClass, onSelectFile: () -> Unit, + onSelectFolder: () -> Unit, onClearFile: () -> Unit, onRemoveFile: (String) -> Unit, onTransferNameChanged: (String) -> Unit, @@ -77,12 +80,13 @@ internal fun TransferComposer( verticalArrangement = Arrangement.spacedBy(16.dp), ) { if (state.selectedFiles.isEmpty()) { - ChooseFileStep(onSelectFile) + ChooseFileStep(onSelectFile, onSelectFolder) } else { ReviewFileStep( state = state, windowClass = windowClass, onSelectFile = onSelectFile, + onSelectFolder = onSelectFolder, onClearFile = onClearFile, onRemoveFile = onRemoveFile, onTransferNameChanged = onTransferNameChanged, @@ -96,7 +100,7 @@ internal fun TransferComposer( } @Composable -private fun ChooseFileStep(onSelectFile: () -> Unit) { +private fun ChooseFileStep(onSelectFile: () -> Unit, onSelectFolder: () -> Unit) { Text(stringResource(Res.string.send_choose_file_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) Text( stringResource(Res.string.send_choose_file_body), @@ -111,6 +115,7 @@ private fun ChooseFileStep(onSelectFile: () -> Unit) { ) { Icon(SendIcons.File, contentDescription = null, tint = LocalVniDropColors.current.brandLink, modifier = Modifier.size(32.dp)) PrimaryButton(stringResource(Res.string.button_choose_files), onClick = onSelectFile) + QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder) } } } @@ -120,6 +125,7 @@ private fun ReviewFileStep( state: SendState, windowClass: WindowClass, onSelectFile: () -> Unit, + onSelectFolder: () -> Unit, onClearFile: () -> Unit, onRemoveFile: (String) -> Unit, onTransferNameChanged: (String) -> Unit, @@ -164,11 +170,13 @@ private fun ReviewFileStep( Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth()) QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing) + QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing) } } else { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { ShareButton(state, coreInitialized, onCreateShare) QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, enabled = !state.isSharing) + QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder, enabled = !state.isSharing) QuietButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = !state.isSharing) } } @@ -199,7 +207,11 @@ private fun SelectedFileCard( 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), + when { + file.isDirectory -> stringResource(Res.string.send_folder_label) + file.sizeBytes != null -> formatBytes(file.sizeBytes) + else -> stringResource(Res.string.send_file_size_unknown) + }, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall, ) 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 index 8027b23..4468fc1 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt @@ -50,9 +50,12 @@ 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.ProgressRow import com.vnidrop.app.ui.components.SecondaryButton +import com.vnidrop.app.ui.state.TransferProgress import com.vnidrop.app.ui.state.displayNameForStatus import com.vnidrop.app.ui.state.formatBytes +import com.vnidrop.app.ui.state.progressForReceiver import com.vnidrop.app.ui.theme.LocalVniDropColors import org.jetbrains.compose.resources.decodeToImageBitmap import org.jetbrains.compose.resources.stringResource @@ -159,28 +162,55 @@ private fun DetailDestination(title: String, description: String, count: Int? = } @Composable -internal fun ReceiverHistoryPanel(receivers: List, loading: Boolean) { +internal fun ReceiverHistoryPanel( + receivers: List, + loading: Boolean, + events: List = emptyList(), + transferTotalSize: ULong? = null, +) { 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) + val sendProgress = when (receiver.status) { + ReceiverDeliveryStatus.Accepted, ReceiverDeliveryStatus.Requested -> + progressForReceiver(events, receiver.transferId, receiver.remoteEndpointId, transferTotalSize) + else -> null + } + ReceiverRow(receiver, sendProgress) } } } } @Composable -private fun ReceiverRow(receiver: ReceiverRequestModel) { +private fun ReceiverRow(receiver: ReceiverRequestModel, sendProgress: TransferProgress? = null) { val name = receiver.receiverName ?: receiver.receiverDeviceName ?: stringResource(Res.string.transfer_nearby_device) - Column(Modifier.fillMaxWidth().padding(vertical = 13.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + val showLiveSend = sendProgress != null && + receiver.status != ReceiverDeliveryStatus.Completed && + receiver.status != ReceiverDeliveryStatus.Refused && + receiver.status != ReceiverDeliveryStatus.Expired + Column(Modifier.fillMaxWidth().padding(vertical = 13.dp), verticalArrangement = Arrangement.spacedBy(6.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) + if (showLiveSend) { + ProgressRow( + label = stringResource(Res.string.transfer_receiver_sending), + progress = sendProgress.progress, + detail = sendProgress.detail, + ) + } else { + Text( + receiverStatusText(receiver.status), + color = receiverStatusColor(receiver.status), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) + } receiver.reason?.takeIf { it.isNotBlank() }?.let { reason -> Text(reason, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall) } 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 53d279a..b068ea0 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 @@ -68,6 +68,105 @@ fun progressForTransfer(events: List, transferId: ULong): Transf ) } +/** + * Live byte progress for one receiver on an outgoing share. + * + * Core emits `transfer` phase events per provider connection with + * `endpoint_id` (and `connection_id`). Multiple blob requests for the same + * receiver are aggregated so multi-file collections show a single bar. + * + * Falls back to mapping `connection_id` → endpoint via provider + * `client-connected` events when older events lack `endpoint_id`. + */ +fun progressForReceiver( + events: List, + transferId: ULong, + remoteEndpointId: String, + totalSizeHint: ULong? = null, +): TransferProgress? { + if (remoteEndpointId.isBlank()) return null + val connectionIds = connectionIdsForEndpoint(events, remoteEndpointId) + val transferEvents = events.filter { event -> + event.transferId == transferId && + event.direction == "send" && + event.phase == "transfer" && + event.kind in setOf("started", "progress", "completed", "aborted") && + eventBelongsToReceiver(event, remoteEndpointId, connectionIds) + } + if (transferEvents.isEmpty()) return null + + val latest = transferEvents.first() + if (latest.kind == "aborted") { + return TransferProgress( + transferId = transferId, + phase = "transfer", + kind = "aborted", + label = "Send interrupted", + progress = null, + detail = null, + ) + } + if (latest.kind == "completed" && transferEvents.none { it.kind == "progress" || it.kind == "started" }) { + return TransferProgress( + transferId = transferId, + phase = "transfer", + kind = "completed", + label = "Send completed", + progress = 1f, + detail = null, + ) + } + + val progress = aggregateReceiverProgress(transferEvents, totalSizeHint) + return TransferProgress( + transferId = transferId, + phase = "transfer", + kind = latest.kind, + label = "Sending", + progress = progress, + detail = progressDetail(latest), + ) +} + +/** + * Best send-side progress for a transfer (any receiver), used on catalog cards + * while status is Sharing. + */ +fun activeSendProgress( + events: List, + transferId: ULong, + totalSizeHint: ULong? = null, +): TransferProgress? { + val endpointIds = events + .asSequence() + .filter { it.transferId == transferId && it.direction == "send" && it.phase == "transfer" } + .mapNotNull { findString(it.dataJson, "endpoint_id") } + .distinct() + .toList() + if (endpointIds.isEmpty()) { + // Fall back to connection-scoped events without endpoint attribution. + val relevant = events.filter { + it.transferId == transferId && + it.direction == "send" && + it.phase == "transfer" && + it.kind in setOf("started", "progress") + } + if (relevant.isEmpty()) return null + return TransferProgress( + transferId = transferId, + phase = "transfer", + kind = relevant.first().kind, + label = "Sending to receiver", + progress = aggregateReceiverProgress(relevant, totalSizeHint), + detail = progressDetail(relevant.first()), + ) + } + return endpointIds + .mapNotNull { progressForReceiver(events, transferId, it, totalSizeHint) } + .firstOrNull { it.kind == "progress" || it.kind == "started" } + ?: endpointIds.mapNotNull { progressForReceiver(events, transferId, it, totalSizeHint) }.firstOrNull() +} + fun summarizeProgress(events: List): List = events .mapNotNull { it.transferId } @@ -181,11 +280,107 @@ private fun findKnownSize(events: List, transferId: ULong): Doub return null } +/** + * Aggregate multi-blob provider progress for one receiver connection. + * + * For each `request_id`, take the latest known size/offset/completion, then + * sum transferred / sum sizes. Prefer the transfer's total size when the + * collection size is known and larger than the sum of observed blob sizes. + */ +private fun aggregateReceiverProgress( + events: List, + totalSizeHint: ULong?, +): Float? { + // Events are newest-first; walk oldest→newest so later states win. + val chronological = events.asReversed() + data class BlobState(var size: Double? = null, var offset: Double = 0.0, var completed: Boolean = false, var aborted: Boolean = false) + val byRequest = linkedMapOf() + var connectionScopedOffset: Double? = null + var connectionScopedSize: Double? = null + + for (event in chronological) { + val requestKey = findNumber(event.dataJson, "request_id")?.toLong()?.toString() + ?: findString(event.dataJson, "request_id") + val size = findNumber(event.dataJson, "size") + val endOffset = findNumber(event.dataJson, "end_offset") + ?: findNumber(event.dataJson, "offset") + ?: findNumber(event.dataJson, "transferred") + + if (requestKey != null) { + val state = byRequest.getOrPut(requestKey) { BlobState() } + if (size != null && size > 0) state.size = size + when (event.kind) { + "progress", "started" -> { + if (endOffset != null) state.offset = maxOf(state.offset, endOffset) + state.aborted = false + } + "completed" -> { + state.completed = true + state.size?.let { state.offset = it } + } + "aborted" -> state.aborted = true + } + } else { + if (size != null && size > 0) connectionScopedSize = size + if (endOffset != null) connectionScopedOffset = endOffset + } + } + + if (byRequest.isNotEmpty()) { + val active = byRequest.values.filterNot { it.aborted } + if (active.isEmpty()) return null + val transferred = active.sumOf { state -> + when { + state.completed -> state.size ?: state.offset + else -> state.offset + } + } + val observedSize = active.mapNotNull { it.size }.sum() + val total = totalSizeHint?.toDouble()?.takeIf { it > 0 } + ?: observedSize.takeIf { it > 0 } + if (total == null || total <= 0.0) return null + return (transferred / total).toFloat().coerceIn(0f, 1f) + } + + val total = totalSizeHint?.toDouble()?.takeIf { it > 0 } + ?: connectionScopedSize?.takeIf { it > 0 } + val transferred = connectionScopedOffset + if (transferred == null || total == null || total <= 0.0) return null + return (transferred / total).toFloat().coerceIn(0f, 1f) +} + +private fun connectionIdsForEndpoint(events: List, remoteEndpointId: String): Set { + val ids = mutableSetOf() + for (event in events) { + val endpoint = findString(event.dataJson, "endpoint_id") ?: continue + if (endpoint != remoteEndpointId) continue + findNumber(event.dataJson, "connection_id")?.toLong()?.toString()?.let(ids::add) + findString(event.dataJson, "connection_id")?.let(ids::add) + } + return ids +} + +private fun eventBelongsToReceiver( + event: CoreEventModel, + remoteEndpointId: String, + connectionIds: Set, +): Boolean { + val endpoint = findString(event.dataJson, "endpoint_id") + if (endpoint != null) return endpoint == remoteEndpointId + val connectionId = findNumber(event.dataJson, "connection_id")?.toLong()?.toString() + ?: findString(event.dataJson, "connection_id") + ?: return false + return connectionId in connectionIds +} + private fun findNumber(json: String, key: String): Double? { val marker = "\"$key\":" val start = json.indexOf(marker) if (start < 0) return null val valueStart = start + marker.length + val raw = json.substring(valueStart).trimStart() + // Skip JSON null so endpoint_id:null does not parse as a number key neighbor. + if (raw.startsWith("null")) return null val valueEnd = json.indexOfAny(charArrayOf(',', '}', ']'), valueStart).takeIf { it >= 0 } ?: json.length return json.substring(valueStart, valueEnd).trim().trim('"').toDoubleOrNull() } @@ -195,6 +390,7 @@ private fun findString(json: String, key: String): String? { val start = json.indexOf(marker) if (start < 0) return null val after = json.substring(start + marker.length).trimStart() + if (after.startsWith("null")) return null if (!after.startsWith('"')) return null val end = after.indexOf('"', 1) if (end <= 1) return null diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index 786abe4..ea6d87e 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -246,6 +246,24 @@ class ViewModelsTest { assertTrue(viewModel.state.value.selectedFiles.isEmpty()) } + @Test + fun sendViewModelNamesFolderSelectionAfterFolderDisplayName() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController()) + advanceUntilIdle() + viewModel.onFilesPicked( + listOf( + com.vnidrop.app.core.PickedShareFile( + value = "/tmp/photos", + displayName = "photos", + isDirectory = true, + ), + ), + ) + assertEquals("photos", viewModel.state.value.transferName) + assertTrue(viewModel.state.value.selectedFiles.single().isDirectory) + } + @Test fun sendDeletionRemovesCoreTransferAndOwnedPreview() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) 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 5765f96..c783ce7 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 @@ -103,6 +103,109 @@ class AppUiModelsTest { assertEquals("a.bin", progress?.detail) } + @Test + fun progressForReceiverAggregatesMultiBlobSendByEndpoint() { + val events = listOf( + // newest first + event( + id = "p2", + phase = "transfer", + kind = "progress", + data = """{"connection_id":9,"request_id":2,"endpoint_id":"peer-a","end_offset":40}""", + direction = "send", + ), + event( + id = "s2", + phase = "transfer", + kind = "started", + data = """{"connection_id":9,"request_id":2,"endpoint_id":"peer-a","size":50}""", + direction = "send", + ), + event( + id = "c1", + phase = "transfer", + kind = "completed", + data = """{"connection_id":9,"request_id":1,"endpoint_id":"peer-a"}""", + direction = "send", + ), + event( + id = "s1", + phase = "transfer", + kind = "started", + data = """{"connection_id":9,"request_id":1,"endpoint_id":"peer-a","size":50}""", + direction = "send", + ), + // different receiver should not mix in + event( + id = "other", + phase = "transfer", + kind = "progress", + data = """{"connection_id":3,"request_id":7,"endpoint_id":"peer-b","end_offset":99}""", + direction = "send", + ), + ) + // blob1 complete 50 + blob2 40 = 90 / total hint 100 + val progress = progressForReceiver(events, 7UL, "peer-a", totalSizeHint = 100UL) + assertEquals(0.9f, progress?.progress) + assertEquals("Sending", progress?.label) + assertEquals(null, progressForReceiver(events, 7UL, "missing")) + } + + @Test + fun progressForReceiverFallsBackToConnectionMap() { + val events = listOf( + event( + id = "prog", + phase = "transfer", + kind = "progress", + data = """{"connection_id":4,"request_id":1,"end_offset":25}""", + direction = "send", + ), + event( + id = "start", + phase = "transfer", + kind = "started", + data = """{"connection_id":4,"request_id":1,"size":100}""", + direction = "send", + ), + CoreEventModel( + id = "conn", + timestamp = 1L, + scope = "endpoint", + transferId = null, + direction = null, + phase = "provider", + kind = "client-connected", + dataJson = """{"connection_id":4,"endpoint_id":"peer-z"}""", + ), + ) + val progress = progressForReceiver(events, 7UL, "peer-z") + assertEquals(0.25f, progress?.progress) + } + + @Test + fun activeSendProgressPicksLiveReceiverSend() { + val events = listOf( + event( + id = "p", + phase = "transfer", + kind = "progress", + data = """{"connection_id":1,"request_id":1,"endpoint_id":"peer-a","end_offset":30}""", + direction = "send", + ), + event( + id = "s", + phase = "transfer", + kind = "started", + data = """{"connection_id":1,"request_id":1,"endpoint_id":"peer-a","size":100}""", + direction = "send", + ), + ) + val progress = activeSendProgress(events, 7UL, totalSizeHint = 100UL) + assertEquals(0.3f, progress?.progress) + assertEquals("Sending", progress?.label) + } + @Test fun canCancelOnlyActiveStatuses() { assertTrue(storedTransfer(status = TransferStatus.Sharing).canCancelTransfer()) @@ -133,12 +236,13 @@ class AppUiModelsTest { kind: String, data: String, transferId: ULong = 7UL, + direction: String = "receive", ) = CoreEventModel( id = id, timestamp = 1L, scope = "transfer", transferId = transferId, - direction = "receive", + direction = direction, phase = phase, kind = kind, dataJson = data, 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 17dca62..2efb573 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 @@ -46,6 +46,32 @@ actual fun rememberShareFilePicker( picker.modalPresentationStyle = UIModalPresentationFormSheet presenter.presentViewController(picker, animated = true, completion = null) } + + @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( + onFilesPicked = { folders -> + val folder = folders.firstOrNull() ?: return@DocumentPickerDelegate + onFilesPicked( + listOf( + folder.copy(isDirectory = true), + ), + ) + }, + onError = onError, + forceDirectory = true, + ) + retainedPickerDelegate = delegate + picker.delegate = delegate + picker.modalPresentationStyle = UIModalPresentationFormSheet + presenter.presentViewController(picker, animated = true, completion = null) + } } } @@ -88,6 +114,7 @@ actual fun rememberReceiveFolderPicker( private class DocumentPickerDelegate( private val onFilesPicked: (List) -> Unit, private val onError: (String) -> Unit, + private val forceDirectory: Boolean = false, ) : NSObject(), UIDocumentPickerDelegateProtocol { override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) { val files = didPickDocumentsAtURLs.mapNotNull { raw -> @@ -95,8 +122,12 @@ private class DocumentPickerDelegate( val displayName = url.lastPathComponent ?: "transfer" val didStartAccess = url.startAccessingSecurityScopedResource() val sizeBytes = try { - val attributes = url.path?.let { NSFileManager.defaultManager.attributesOfItemAtPath(it, null) } - (attributes?.get(NSFileSize) as? NSNumber)?.unsignedLongLongValue + if (forceDirectory) { + null + } else { + val attributes = url.path?.let { NSFileManager.defaultManager.attributesOfItemAtPath(it, null) } + (attributes?.get(NSFileSize) as? NSNumber)?.unsignedLongLongValue + } } finally { if (didStartAccess) url.stopAccessingSecurityScopedResource() } @@ -105,6 +136,7 @@ private class DocumentPickerDelegate( displayName, sizeBytes, nativeFileIcon(url), + isDirectory = forceDirectory, ) } if (files.isEmpty()) { 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 index af6e478..02f7ee8 100644 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt @@ -56,7 +56,7 @@ private class IosFileSystemService : FileSystemService { kind = uniffi.vnidrop.SourceKind.IOS_SECURITY_SCOPED_URL, value = file.value, displayName = file.displayName, - isDirectory = false, + isDirectory = file.isDirectory, ) } return repository.shareSources(sources, transferName, senderName, accessPolicy) diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt index bf6f66f..c435555 100644 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt @@ -2,30 +2,91 @@ package com.vnidrop.app.feature.receive import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCAction +import kotlinx.cinterop.ObjCObjectVar +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr import kotlinx.cinterop.readBytes +import kotlinx.cinterop.value +import platform.AVFoundation.AVAuthorizationStatusAuthorized +import platform.AVFoundation.AVAuthorizationStatusDenied +import platform.AVFoundation.AVAuthorizationStatusNotDetermined +import platform.AVFoundation.AVAuthorizationStatusRestricted +import platform.AVFoundation.AVCaptureDevice +import platform.AVFoundation.AVCaptureDeviceInput +import platform.AVFoundation.AVCaptureMetadataOutput +import platform.AVFoundation.AVCaptureMetadataOutputObjectsDelegateProtocol +import platform.AVFoundation.AVCaptureOutput +import platform.AVFoundation.AVCaptureConnection +import platform.AVFoundation.AVCaptureSession +import platform.AVFoundation.AVCaptureSessionPresetHigh +import platform.AVFoundation.AVCaptureVideoPreviewLayer +import platform.AVFoundation.AVLayerVideoGravityResizeAspectFill +import platform.AVFoundation.AVMediaTypeVideo +import platform.AVFoundation.AVMetadataMachineReadableCodeObject +import platform.AVFoundation.AVMetadataObjectTypeQRCode +import platform.AVFoundation.authorizationStatusForMediaType +import platform.AVFoundation.requestAccessForMediaType +import platform.CoreGraphics.CGRectMake +import platform.CoreNFC.NFCNDEFMessage +import platform.CoreNFC.NFCNDEFPayload +import platform.CoreNFC.NFCNDEFReaderSession +import platform.CoreNFC.NFCNDEFReaderSessionDelegateProtocol +import platform.CoreNFC.NFCTypeNameFormatMedia +import platform.Foundation.NSData +import platform.Foundation.NSError import platform.Foundation.NSFileManager import platform.Foundation.NSURL +import platform.UIKit.NSTextAlignmentCenter import platform.UIKit.UIApplication +import platform.UIKit.UIButton +import platform.UIKit.UIButtonTypeSystem +import platform.UIKit.UIColor +import platform.UIKit.UIControlEventTouchUpInside +import platform.UIKit.UIControlStateNormal import platform.UIKit.UIDocumentPickerDelegateProtocol import platform.UIKit.UIDocumentPickerViewController +import platform.UIKit.UILabel import platform.UIKit.UIModalPresentationFormSheet +import platform.UIKit.UIModalPresentationFullScreen +import platform.UIKit.UIViewAutoresizingFlexibleHeight +import platform.UIKit.UIViewAutoresizingFlexibleWidth +import platform.UIKit.UIViewController import platform.UniformTypeIdentifiers.UTTypeData +import platform.darwin.DISPATCH_QUEUE_PRIORITY_DEFAULT import platform.darwin.NSObject +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_global_queue +import platform.darwin.dispatch_get_main_queue private var retainedInvitationDelegate: InvitationDocumentDelegate? = null +private var retainedQrScanner: QrScannerViewController? = null +private var retainedNfcReader: InvitationNfcReader? = null @Composable actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = remember { object : ReceiveInvitationActions { override val fileAvailability = ReceiveMethodAvailability.Available - // Hide unfinished iOS methods so the method list only shows what works. - override val qrAvailability = ReceiveMethodAvailability.Hidden - override val nfcAvailability = ReceiveMethodAvailability.Hidden + override val qrAvailability = + if (AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo) != null) { + ReceiveMethodAvailability.Available + } else { + ReceiveMethodAvailability.Unavailable + } + override val nfcAvailability = + if (NFCNDEFReaderSession.readingAvailable) { + ReceiveMethodAvailability.Available + } else { + ReceiveMethodAvailability.Unavailable + } @OptIn(ExperimentalForeignApi::class) override fun pickInvitation(onResult: (Result) -> Unit) { - val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController + cancel() + val presenter = topPresenter() ?: return onResult(Result.failure(IllegalStateException("Could not find an iOS view controller"))) val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeData), asCopy = true) val delegate = InvitationDocumentDelegate(onResult) @@ -35,13 +96,67 @@ actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = rememb presenter.presentViewController(picker, animated = true, completion = null) } - override fun scanQrCode(onResult: (Result) -> Unit) = - onResult(Result.failure(UnsupportedOperationException("QR scanning is not enabled for this iOS build"))) + override fun scanQrCode(onResult: (Result) -> Unit) { + cancel() + val presenter = topPresenter() + ?: return onResult(Result.failure(IllegalStateException("Could not find an iOS view controller"))) + ensureCameraAccess { granted -> + if (!granted) { + onResult(Result.failure(IllegalStateException("Camera access is required to scan QR codes"))) + return@ensureCameraAccess + } + val scanner = QrScannerViewController { result -> + retainedQrScanner = null + onResult(result) + } + retainedQrScanner = scanner + scanner.modalPresentationStyle = UIModalPresentationFullScreen + presenter.presentViewController(scanner, animated = true, completion = null) + } + } - override fun readNfcInvitation(onResult: (Result) -> Unit) = - onResult(Result.failure(UnsupportedOperationException("NFC reading is not enabled for this iOS build"))) + override fun readNfcInvitation(onResult: (Result) -> Unit) { + cancel() + if (!NFCNDEFReaderSession.readingAvailable) { + onResult(Result.failure(UnsupportedOperationException("NFC reading is unavailable on this device"))) + return + } + val reader = InvitationNfcReader { result -> + retainedNfcReader = null + onResult(result) + } + retainedNfcReader = reader + reader.start() + } - override fun cancel() = Unit + override fun cancel() { + retainedNfcReader?.cancel() + retainedNfcReader = null + retainedQrScanner?.cancelScan() + retainedQrScanner = null + retainedInvitationDelegate = null + } + } +} + +private fun topPresenter(): UIViewController? { + var controller = UIApplication.sharedApplication.keyWindow?.rootViewController + while (controller?.presentedViewController != null) { + controller = controller?.presentedViewController + } + return controller +} + +private fun ensureCameraAccess(onResult: (Boolean) -> Unit) { + when (AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo)) { + AVAuthorizationStatusAuthorized -> onResult(true) + AVAuthorizationStatusNotDetermined -> { + AVCaptureDevice.requestAccessForMediaType(AVMediaTypeVideo) { granted -> + dispatch_async(dispatch_get_main_queue()) { onResult(granted) } + } + } + AVAuthorizationStatusDenied, AVAuthorizationStatusRestricted -> onResult(false) + else -> onResult(false) } } @@ -50,9 +165,9 @@ private class InvitationDocumentDelegate( ) : NSObject(), UIDocumentPickerDelegateProtocol { @OptIn(ExperimentalForeignApi::class) override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) { - val url = didPickDocumentsAtURLs.firstOrNull() as? NSURL onResult(runCatching { - requireNotNull(url) { "The selected invitation URL was invalid" } + val url = didPickDocumentsAtURLs.firstOrNull() as? NSURL + ?: error("The selected invitation URL was invalid") val path = url.path ?: error("The invitation path was invalid") val data = NSFileManager.defaultManager.contentsAtPath(path) ?: error("The invitation could not be opened") val length = data.length.toInt() @@ -67,3 +182,203 @@ private class InvitationDocumentDelegate( retainedInvitationDelegate = null } } + +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +private class QrScannerViewController( + private val onResult: (Result) -> Unit, +) : UIViewController(nibName = null, bundle = null), AVCaptureMetadataOutputObjectsDelegateProtocol { + private val session = AVCaptureSession() + private var previewLayer: AVCaptureVideoPreviewLayer? = null + private var finished = false + private val closeTarget = ButtonTarget { cancelScan() } + + override fun viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor.blackColor + + val hint = UILabel(frame = view.bounds).apply { + text = "Point the camera at a VniDrop QR code" + textColor = UIColor.whiteColor + textAlignment = NSTextAlignmentCenter + numberOfLines = 0 + autoresizingMask = UIViewAutoresizingFlexibleWidth or UIViewAutoresizingFlexibleHeight + } + view.addSubview(hint) + + val close = UIButton.buttonWithType(UIButtonTypeSystem).apply { + setTitle("Cancel", forState = UIControlStateNormal) + setTitleColor(UIColor.whiteColor, forState = UIControlStateNormal) + addTarget(closeTarget, platform.objc.sel_registerName("invoke"), UIControlEventTouchUpInside) + setFrame(CGRectMake(16.0, 52.0, 88.0, 36.0)) + } + view.addSubview(close) + configureSession() + } + + override fun viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.setFrame(view.bounds) + } + + override fun viewWillDisappear(animated: Boolean) { + super.viewWillDisappear(animated) + if (session.running) session.stopRunning() + } + + fun cancelScan() { + finish(Result.failure(IllegalStateException("QR scanning was cancelled"))) + } + + private fun configureSession() { + val device = AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo) + ?: return finish(Result.failure(IllegalStateException("No camera is available"))) + + memScoped { + val errorPtr = alloc>() + val input = AVCaptureDeviceInput.deviceInputWithDevice(device, errorPtr.ptr) + if (input == null) { + finish( + Result.failure( + IllegalStateException(errorPtr.value?.localizedDescription ?: "Could not open the camera"), + ), + ) + return + } + if (!session.canAddInput(input)) { + finish(Result.failure(IllegalStateException("Could not configure the camera input"))) + return + } + session.addInput(input) + } + + val output = AVCaptureMetadataOutput() + if (!session.canAddOutput(output)) { + finish(Result.failure(IllegalStateException("Could not configure the QR scanner"))) + return + } + session.addOutput(output) + output.setMetadataObjectsDelegate(this, queue = dispatch_get_main_queue()) + output.metadataObjectTypes = listOf(AVMetadataObjectTypeQRCode) + + val layer = AVCaptureVideoPreviewLayer(session = session).apply { + videoGravity = AVLayerVideoGravityResizeAspectFill + setFrame(view.bounds) + } + view.layer.insertSublayer(layer, atIndex = 0u) + previewLayer = layer + session.sessionPreset = AVCaptureSessionPresetHigh + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT.toLong(), 0u)) { + session.startRunning() + } + } + + override fun captureOutput( + output: AVCaptureOutput, + didOutputMetadataObjects: List<*>, + fromConnection: AVCaptureConnection, + ) { + val code = didOutputMetadataObjects + .mapNotNull { it as? AVMetadataMachineReadableCodeObject } + .firstOrNull { it.type == AVMetadataObjectTypeQRCode } + val value = code?.stringValue?.trim().orEmpty() + if (value.isNotEmpty()) { + finish(Result.success(value)) + } + } + + private fun finish(result: Result) { + if (finished) return + finished = true + if (session.running) session.stopRunning() + if (presentingViewController != null) { + dismissViewControllerAnimated(true) { onResult(result) } + } else { + onResult(result) + } + } +} + +@OptIn(BetaInteropApi::class) +private class ButtonTarget( + private val onClick: () -> Unit, +) : NSObject() { + @ObjCAction + fun invoke() { + onClick() + } +} + +@OptIn(ExperimentalForeignApi::class) +private class InvitationNfcReader( + private val onResult: (Result) -> Unit, +) : NSObject(), NFCNDEFReaderSessionDelegateProtocol { + private var session: NFCNDEFReaderSession? = null + private var finished = false + + fun start() { + val reader = NFCNDEFReaderSession(this, dispatch_get_main_queue(), invalidateAfterFirstRead = true) + reader.alertMessage = "Hold your iPhone near a VniDrop invitation tag" + session = reader + reader.beginSession() + } + + fun cancel() { + session?.invalidateSession() + session = null + } + + override fun readerSession(session: NFCNDEFReaderSession, didInvalidateWithError: NSError) { + if (finished) return + // NFCReaderError.readerSessionInvalidationErrorUserCanceled == 200 + val cancelled = didInvalidateWithError.code == 200L + finish( + if (cancelled) { + Result.failure(IllegalStateException("NFC reading was cancelled")) + } else { + Result.failure( + IllegalStateException(didInvalidateWithError.localizedDescription ?: "NFC reading failed"), + ) + }, + ) + } + + override fun readerSession(session: NFCNDEFReaderSession, didDetectNDEFs: List<*>) { + val ticket = runCatching { + val messages = didDetectNDEFs.mapNotNull { it as? NFCNDEFMessage } + messages + .flatMap { message -> message.records.mapNotNull { it as? NFCNDEFPayload } } + .firstNotNullOfOrNull(::payloadAsInvitation) + ?: error("This NFC tag does not contain a VniDrop invitation") + } + session.invalidateSession() + finish(ticket) + } + + private fun finish(result: Result) { + if (finished) return + finished = true + session = null + dispatch_async(dispatch_get_main_queue()) { onResult(result) } + } +} + +@OptIn(ExperimentalForeignApi::class) +private fun payloadAsInvitation(payload: NFCNDEFPayload): String? { + val type = payload.type?.toByteArray()?.decodeToString() ?: return null + val data = payload.payload?.toByteArray() ?: return null + return when { + payload.typeNameFormat == NFCTypeNameFormatMedia && type == InvitationMimeType -> + decodeInvitationBytes(data) + payload.typeNameFormat == NFCTypeNameFormatMedia && type.startsWith("text/") -> + decodeInvitationBytes(data) + else -> null + } +} + +@OptIn(ExperimentalForeignApi::class) +private fun NSData.toByteArray(): ByteArray { + val length = this.length.toInt() + if (length <= 0) return ByteArray(0) + return this.bytes?.readBytes(length) ?: ByteArray(0) +} 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 index ca9be15..130e3d3 100644 --- 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 @@ -2,27 +2,47 @@ package com.vnidrop.app.feature.send import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import kotlinx.cinterop.ExperimentalForeignApi +import com.vnidrop.app.feature.receive.VniDropInvitationMimeType import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCSignatureOverride +import platform.CoreNFC.NFCNDEFMessage +import platform.CoreNFC.NFCNDEFPayload +import platform.CoreNFC.NFCNDEFReaderSession +import platform.CoreNFC.NFCNDEFReaderSessionDelegateProtocol +import platform.CoreNFC.NFCNDEFStatusNotSupported +import platform.CoreNFC.NFCNDEFStatusReadOnly +import platform.CoreNFC.NFCNDEFTagProtocol +import platform.CoreNFC.NFCTypeNameFormatMedia +import platform.Foundation.NSData +import platform.Foundation.NSError import platform.Foundation.NSString import platform.Foundation.NSTemporaryDirectory -import platform.Foundation.NSURL import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.NSURL import platform.Foundation.create +import platform.Foundation.dataUsingEncoding import platform.Foundation.writeToFile import platform.UIKit.UIActivityViewController import platform.UIKit.UIApplication import platform.UIKit.UIDocumentPickerViewController import platform.UIKit.UIModalPresentationFormSheet +import platform.darwin.NSObject +import platform.darwin.dispatch_get_main_queue + +private var retainedNfcWriter: InvitationNfcWriter? = null @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 val nfcAvailability = + if (NFCNDEFReaderSession.readingAvailable) { + NfcShareAvailability.Available + } else { + NfcShareAvailability.Unavailable + } override fun exportInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) { onResult(runCatching { @@ -42,9 +62,23 @@ actual fun rememberTransferShareActions(): TransferShareActions = remember { } override fun writeInvitationToNfc(ticket: String, onResult: (Result) -> Unit) { - onResult(Result.failure(UnsupportedOperationException("NFC tag writing is not enabled for this build"))) + cancelNfcWrite() + if (!NFCNDEFReaderSession.readingAvailable) { + onResult(Result.failure(UnsupportedOperationException("NFC is unavailable on this device"))) + return + } + val writer = InvitationNfcWriter(ticket) { result -> + retainedNfcWriter = null + onResult(result) + } + retainedNfcWriter = writer + writer.start() + } + + override fun cancelNfcWrite() { + retainedNfcWriter?.cancel() + retainedNfcWriter = null } - override fun cancelNfcWrite() = Unit } } @@ -64,3 +98,106 @@ private fun presenter() = UIApplication.sharedApplication.keyWindow?.rootViewCon private fun present(controller: platform.UIKit.UIViewController) { presenter().presentViewController(controller, animated = true, completion = null) } + +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +private class InvitationNfcWriter( + private val ticket: String, + private val onResult: (Result) -> Unit, +) : NSObject(), NFCNDEFReaderSessionDelegateProtocol { + private var session: NFCNDEFReaderSession? = null + private var finished = false + + fun start() { + val reader = NFCNDEFReaderSession(this, dispatch_get_main_queue(), invalidateAfterFirstRead = false) + reader.alertMessage = "Hold your iPhone near a writable NFC tag" + session = reader + reader.beginSession() + } + + fun cancel() { + session?.invalidateSession() + session = null + } + + override fun readerSession(session: NFCNDEFReaderSession, didInvalidateWithError: NSError) { + if (finished) return + // NFCReaderError.readerSessionInvalidationErrorUserCanceled == 200 + val cancelled = didInvalidateWithError.code == 200L + finish( + if (cancelled) { + Result.failure(IllegalStateException("NFC writing was cancelled")) + } else { + Result.failure( + IllegalStateException(didInvalidateWithError.localizedDescription), + ) + }, + ) + } + + @ObjCSignatureOverride + override fun readerSession(session: NFCNDEFReaderSession, didDetectNDEFs: List<*>) { + // Prefer tag-based write path via didDetectTags when available. + } + + @ObjCSignatureOverride + override fun readerSession(session: NFCNDEFReaderSession, didDetectTags: List<*>) { + val tag = didDetectTags.firstOrNull() as? NFCNDEFTagProtocol + ?: return finish(Result.failure(IllegalStateException("No NFC tag was detected"))) + + session.connectToTag(tag) { connectError -> + if (connectError != null) { + finish(Result.failure(IllegalStateException(connectError.localizedDescription))) + return@connectToTag + } + tag.queryNDEFStatusWithCompletionHandler { status, _, queryError -> + if (queryError != null) { + finish(Result.failure(IllegalStateException(queryError.localizedDescription))) + return@queryNDEFStatusWithCompletionHandler + } + when (status) { + NFCNDEFStatusNotSupported -> { + finish(Result.failure(IllegalStateException("This NFC tag does not support NDEF"))) + } + NFCNDEFStatusReadOnly -> { + finish(Result.failure(IllegalStateException("This NFC tag is read-only"))) + } + else -> { + val message = invitationNdefMessage(ticket) + ?: return@queryNDEFStatusWithCompletionHandler finish( + Result.failure(IllegalStateException("Could not encode the invitation for NFC")), + ) + tag.writeNDEF(message) { writeError -> + if (writeError != null) { + finish(Result.failure(IllegalStateException(writeError.localizedDescription))) + } else { + session.alertMessage = "Invitation written" + session.invalidateSession() + finish(Result.success(Unit)) + } + } + } + } + } + } + } + + private fun finish(result: Result) { + if (finished) return + finished = true + session = null + onResult(result) + } +} + +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +private fun invitationNdefMessage(ticket: String): NFCNDEFMessage? { + val type = NSString.create(string = VniDropInvitationMimeType).dataUsingEncoding(NSUTF8StringEncoding) ?: return null + val payload = NSString.create(string = ticket).dataUsingEncoding(NSUTF8StringEncoding) ?: return null + val record = NFCNDEFPayload( + format = NFCTypeNameFormatMedia, + type = type, + identifier = NSData(), + payload = payload, + ) + return NFCNDEFMessage(nDEFRecords = listOf(record)) +} 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 81232b5..052d82f 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 @@ -25,6 +25,23 @@ actual fun rememberShareFilePicker( if (selected.isNotEmpty()) onFilesPicked(selected) } } + + override fun pickFolder() { + openPicker(onError) { + val selected = pickDirectory(title = "Select folder to share") ?: return@openPicker + onFilesPicked( + listOf( + PickedShareFile( + value = selected.absolutePath, + displayName = selected.name.ifBlank { selected.absolutePath }, + sizeBytes = null, + thumbnailBytes = selected.systemIconPng(), + isDirectory = true, + ), + ), + ) + } + } } } @@ -36,7 +53,7 @@ actual fun rememberReceiveFolderPicker( object : ReceiveFolderPicker { override fun pickFolder() { openPicker(onError) { - val selected = pickDirectory() ?: return@openPicker + val selected = pickDirectory(title = "Select receive folder") ?: return@openPicker onFolderPicked( ReceiveFolder( kind = ReceiveFolderKind.FileSystemPath, @@ -111,10 +128,10 @@ private fun File.systemIconPng(): ByteArray? = runCatching { } }.getOrNull() -private fun pickDirectory(): File? = +private fun pickDirectory(title: String): File? = if (isMacOs()) { val dialog = withMacDirectoryDialog { - nativeFileDialog("Select receive folder").apply { isVisible = true } + nativeFileDialog(title).apply { isVisible = true } } try { val directory = dialog.directory ?: return null @@ -126,7 +143,7 @@ private fun pickDirectory(): File? = } } else { val chooser = JFileChooser().apply { - dialogTitle = "Select receive folder" + dialogTitle = title fileSelectionMode = JFileChooser.DIRECTORIES_ONLY isAcceptAllFileFilterUsed = false } 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 index 7a97307..ef26c9f 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt @@ -41,7 +41,7 @@ private class JvmFileSystemService : FileSystemService { kind = uniffi.vnidrop.SourceKind.PATH, value = file.value, displayName = file.displayName, - isDirectory = false, + isDirectory = file.isDirectory || File(file.value).isDirectory, ) } return repository.shareSources(sources, transferName, senderName, accessPolicy)