Merge pull request #9 from vnidrop/feat/step3-remaining

feat(send): finish step 3 — iOS QR/NFC, folders, per-receiver progress
This commit is contained in:
Hammed Abass
2026-07-12 19:13:35 +02:00
committed by GitHub
25 changed files with 1302 additions and 153 deletions

View File

@@ -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<CoreInner>,
}
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<F: Future>(&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<AccessPolicy>,
active_transfers: TokioMutex<HashMap<u64, ActiveTransfer>>,
/// Sync mutex so cancel can remove + signal without awaiting (and without
/// holding a Tokio lock across repository I/O).
active_transfers: std::sync::Mutex<HashMap<u64, ActiveTransfer>>,
// 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<ShareSource>,
metadata: ShareMetadataInput,
) -> Result<ShareResult, VnidropError> {
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<u64, VnidropError> {
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<Vec<ReceiverRequest>, 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<String>,
) -> 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<Vec<StoredTransfer>, 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<u64>) -> Result<Vec<CoreEvent>, 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<TransferDirection> {
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<Self>,
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,
}),
),
}
}

View File

@@ -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));
}
}

View File

@@ -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<HashMap<String, &'static str>>,
fail_writes: bool,
write_delay: Duration,
/// When set, every write waits until [Self::release_write_gate] opens the gate.
write_gate: Option<Arc<(Mutex<bool>, 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<u8>) -> 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);
}

View File

@@ -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;

View File

@@ -43,6 +43,10 @@
</array>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>VniDrop uses the camera to scan transfer QR codes.</string>
<key>NFCReaderUsageDescription</key>
<string>VniDrop uses NFC to read transfer invitation tags.</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
</dict>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.nfc.readersession.formats</key>
<array>
<string>NDEF</string>
</array>
</dict>
</plist>

View File

@@ -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)
}
}
}

View File

@@ -71,12 +71,18 @@ private class AndroidFileSystemService(
accessPolicy: ShareAccessPolicy,
): Result<Share> = 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<PickedShareFile> {
val treeUri = Uri.parse(folder.value)
val rootId = DocumentsContract.getTreeDocumentId(treeUri)
val out = mutableListOf<PickedShareFile>()
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.
*

View File

@@ -10,8 +10,9 @@
<string name="send_transfers_title">Your transfers</string>
<string name="send_new_transfer_title">New transfer</string>
<string name="send_choose_file_title">Choose what to share</string>
<string name="send_choose_file_body">Select one or more files from this device. You can review them before creating the transfer.</string>
<string name="send_choose_file_body">Select files or a folder from this device. You can review the selection before creating the transfer.</string>
<string name="send_selected_files_count">%1$d files selected</string>
<string name="send_folder_label">Folder</string>
<string name="button_remove_file">Remove file</string>
<string name="button_choose_files">Choose files</string>
<string name="button_change_files">Change files</string>
@@ -122,6 +123,7 @@
<string name="progress_preparing">Preparing transfer</string>
<string name="transfer_receivers_pending">%1$d waiting</string>
<string name="transfer_receivers_completed_count">%1$d completed</string>
<string name="transfer_receiver_sending">Sending</string>
<string name="transfer_event_downloading">Downloading files</string>
<string name="transfer_event_saving">Saving files</string>
<string name="transfer_event_connecting">Connecting to sender</string>

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<PickedShareFile>) {
if (files.isEmpty()) return
@@ -271,9 +273,11 @@ class SendViewModel(
}
}
private fun defaultTransferName(files: List<PickedShareFile>): String = when (files.size) {
0 -> ""
1 -> files.first().displayName
private fun defaultTransferName(files: List<PickedShareFile>): 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"
}

View File

@@ -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,
)

View File

@@ -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<ReceiverRequestModel>, loading: Boolean) {
internal fun ReceiverHistoryPanel(
receivers: List<ReceiverRequestModel>,
loading: Boolean,
events: List<CoreEventModel> = 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)
}

View File

@@ -68,6 +68,105 @@ fun progressForTransfer(events: List<CoreEventModel>, 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<CoreEventModel>,
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<CoreEventModel>,
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<CoreEventModel>): List<TransferProgress> =
events
.mapNotNull { it.transferId }
@@ -181,11 +280,107 @@ private fun findKnownSize(events: List<CoreEventModel>, 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<CoreEventModel>,
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<String, BlobState>()
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<CoreEventModel>, remoteEndpointId: String): Set<String> {
val ids = mutableSetOf<String>()
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<String>,
): 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

View File

@@ -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))

View File

@@ -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,

View File

@@ -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<PickedShareFile>) -> 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()) {

View File

@@ -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)

View File

@@ -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<String>) -> 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<String>) -> Unit) =
onResult(Result.failure(UnsupportedOperationException("QR scanning is not enabled for this iOS build")))
override fun scanQrCode(onResult: (Result<String>) -> 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<String>) -> Unit) =
onResult(Result.failure(UnsupportedOperationException("NFC reading is not enabled for this iOS build")))
override fun readNfcInvitation(onResult: (Result<String>) -> 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<String>) -> 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<ObjCObjectVar<NSError?>>()
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<String>) {
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<String>) -> 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<String>) {
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)
}

View File

@@ -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>) -> Unit) {
onResult(runCatching {
@@ -42,9 +62,23 @@ actual fun rememberTransferShareActions(): TransferShareActions = remember {
}
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> 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>) -> 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<Unit>) {
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))
}

View File

@@ -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
}

View File

@@ -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)