From 3cdbf7e8f94a162aa7fe0cc52844f9273d4f0760 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Fri, 3 Jul 2026 00:24:25 +0200 Subject: [PATCH] Add mobile file access sources --- Cargo.lock | 1 + crates/vnidrop/Cargo.toml | 1 + crates/vnidrop/src/api.rs | 1 + crates/vnidrop/src/filesystem.rs | 96 +++++++++++++++-- crates/vnidrop/src/runtime.rs | 14 ++- crates/vnidrop/src/tests.rs | 32 +++++- .../app/core/PlatformFileAccess.android.kt | 9 ++ .../com/vnidrop/app/core/CoreRepository.kt | 101 ++++++++++++++++-- .../vnidrop/app/core/PlatformFileAccess.kt | 13 +++ .../app/core/PlatformFileAccess.ios.kt | 24 +++++ .../app/core/PlatformFileAccess.jvm.kt | 9 ++ 11 files changed, 272 insertions(+), 29 deletions(-) create mode 100644 shared/src/androidMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.android.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.kt create mode 100644 shared/src/iosMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.ios.kt create mode 100644 shared/src/jvmMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.jvm.kt diff --git a/Cargo.lock b/Cargo.lock index c418cf9..4948488 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5025,6 +5025,7 @@ dependencies = [ "iroh", "iroh-blobs", "irpc", + "libc", "n0-future", "num_cpus", "serde", diff --git a/crates/vnidrop/Cargo.toml b/crates/vnidrop/Cargo.toml index b569c45..0499954 100644 --- a/crates/vnidrop/Cargo.toml +++ b/crates/vnidrop/Cargo.toml @@ -17,6 +17,7 @@ futures-lite = "2.6.1" iroh = "1.0.0" iroh-blobs = "0.103.0" irpc = "0.17.0" +libc = "0.2.186" n0-future = "0.3.1" num_cpus = "1.17.0" serde = { version = "1", features = ["derive"] } diff --git a/crates/vnidrop/src/api.rs b/crates/vnidrop/src/api.rs index 7471f0d..65de270 100644 --- a/crates/vnidrop/src/api.rs +++ b/crates/vnidrop/src/api.rs @@ -31,6 +31,7 @@ pub struct RuntimeStatus { #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)] pub enum SourceKind { Path, + FileDescriptor, AndroidContentUri, IosSecurityScopedUrl, } diff --git a/crates/vnidrop/src/filesystem.rs b/crates/vnidrop/src/filesystem.rs index 77f25ed..7eac03e 100644 --- a/crates/vnidrop/src/filesystem.rs +++ b/crates/vnidrop/src/filesystem.rs @@ -1,4 +1,7 @@ +#[cfg(unix)] +use std::os::fd::{FromRawFd, OwnedFd}; use std::{ + fs::File, io::{self, Read, Write}, path::{Component, Path, PathBuf}, }; @@ -25,15 +28,38 @@ pub(crate) struct TransferImport { #[derive(Debug)] pub(crate) struct ImportSourceFile { - pub(crate) path: PathBuf, + pub(crate) source: ImportSource, pub(crate) collection_name: String, } +#[derive(Debug)] +pub(crate) enum ImportSource { + Path(PathBuf), + #[cfg(unix)] + FileDescriptor(OwnedFd), +} + +impl ImportSource { + pub(crate) fn open(self) -> Result { + match self { + Self::Path(path) => { + File::open(&path).with_context(|| format!("failed to open {}", path.display())) + } + #[cfg(unix)] + Self::FileDescriptor(fd) => Ok(File::from(fd)), + } + } +} + pub(crate) fn collect_import_files(sources: Vec) -> Result> { let mut files = Vec::new(); for source in sources { match source.kind { SourceKind::Path | SourceKind::IosSecurityScopedUrl => { + // iOS security-scoped resources are still ordinary paths once + // the platform side has started the lease. Rust deliberately + // does not try to own that lease; it only streams while Kotlin + // keeps the URL accessible. let path = source_path(&source)?; let display_name = source .display_name @@ -49,14 +75,42 @@ pub(crate) fn collect_import_files(sources: Vec) -> Result { + #[cfg(not(unix))] + { + anyhow::bail!( + "file descriptor sources are only supported on Unix-like targets" + ); + } + #[cfg(unix)] + { + if source.is_directory { + anyhow::bail!("file descriptor sources cannot represent directories yet"); + } + let display_name = source + .display_name + .clone() + .and_then(non_empty) + .unwrap_or_else(|| "transfer".to_string()); + // Android SAF/content URIs are not paths. Platform code opens + // the URI as a ParcelFileDescriptor, then Rust duplicates the + // borrowed fd here and streams from its owned duplicate. + files.push(ImportSourceFile { + source: ImportSource::FileDescriptor(duplicate_file_descriptor( + &source.value, + )?), collection_name: validated_relative_string(&display_name)?, }); } } SourceKind::AndroidContentUri => { anyhow::bail!( - "Android content URI streaming needs platform file descriptor glue before it can be imported without copying" + "Android content URIs are not filesystem paths; open a ParcelFileDescriptor in Kotlin and pass its fd as SourceKind.FileDescriptor" ); } } @@ -68,13 +122,14 @@ pub(crate) fn collect_import_files(sources: Vec) -> Result Result { - if matches!(source.kind, SourceKind::IosSecurityScopedUrl) - && source.value.starts_with("file://") - { - let without_scheme = source.value.trim_start_matches("file://"); + platform_path(&source.value) +} + +pub(crate) fn platform_path(value: &str) -> Result { + if let Some(without_scheme) = value.strip_prefix("file://") { return Ok(PathBuf::from(percent_decode_file_url_path(without_scheme)?)); } - Ok(PathBuf::from(&source.value)) + Ok(PathBuf::from(value)) } fn collect_dir_files( @@ -93,7 +148,7 @@ fn collect_dir_files( .context("failed to compute relative path")?; let collection_name = path_to_string(Path::new(display_name).join(relative), true)?; files.push(ImportSourceFile { - path: entry.path().to_path_buf(), + source: ImportSource::Path(entry.path().to_path_buf()), collection_name, }); } @@ -121,6 +176,9 @@ pub(crate) fn read_stream_from_blocking_reader( where R: Read + Send + 'static, { + // Keep the FFI boundary out of the data path: blocking OS reads feed a + // small bounded channel, so large files are streamed into iroh-blobs + // without copying the whole file through Kotlin memory. let (tx, rx) = async_channel::bounded(2); std::thread::spawn(move || { let mut buffer = vec![0; STREAM_BUFFER_LEN]; @@ -225,3 +283,23 @@ pub(crate) fn percent_decode_file_url_path(value: &str) -> Result { } Ok(String::from_utf8(output)?) } + +#[cfg(unix)] +fn duplicate_file_descriptor(value: &str) -> Result { + let fd = value + .parse::() + .context("file descriptor source value must be an integer fd")?; + if fd < 0 { + anyhow::bail!("file descriptor must be non-negative"); + } + + // Android's ParcelFileDescriptor remains owned by Kotlin. Rust duplicates + // it immediately so the blocking import thread can close its own handle + // without racing or invalidating the platform owner. + let duplicated = unsafe { libc::dup(fd) }; + if duplicated < 0 { + return Err(io::Error::last_os_error()).context("failed to duplicate file descriptor"); + } + let owned = unsafe { OwnedFd::from_raw_fd(duplicated) }; + Ok(owned) +} diff --git a/crates/vnidrop/src/runtime.rs b/crates/vnidrop/src/runtime.rs index 2c9cbe2..b7a67f9 100644 --- a/crates/vnidrop/src/runtime.rs +++ b/crates/vnidrop/src/runtime.rs @@ -31,8 +31,9 @@ use crate::{ }, error::VnidropError, filesystem::{ - collect_import_files, default_collection_name, read_stream_from_blocking_reader, - safe_output_path, wait_for_writer, write_stream_to_blocking_writer, TransferImport, + collect_import_files, default_collection_name, platform_path, + read_stream_from_blocking_reader, safe_output_path, wait_for_writer, + write_stream_to_blocking_writer, TransferImport, }, logging::init_logging, repository::Repository, @@ -97,11 +98,9 @@ impl VnidropCore { output_dir: String, receiver_name: Option, ) -> Result<(), VnidropError> { + let output_dir = platform_path(&output_dir)?; self.runtime - .block_on( - self.inner - .receive(ticket, PathBuf::from(output_dir), receiver_name), - ) + .block_on(self.inner.receive(ticket, output_dir, receiver_name)) .map_err(Into::into) } @@ -519,8 +518,7 @@ impl CoreInner { .map(|file| { let core = self.clone(); async move { - let reader = File::open(&file.path) - .with_context(|| format!("failed to open {}", file.path.display()))?; + let reader = file.source.open()?; let stream = read_stream_from_blocking_reader(reader); let import = core.store.add_stream(stream).await; let (tag, size) = core diff --git a/crates/vnidrop/src/tests.rs b/crates/vnidrop/src/tests.rs index f8b5c8f..6cf9dd5 100644 --- a/crates/vnidrop/src/tests.rs +++ b/crates/vnidrop/src/tests.rs @@ -1,14 +1,19 @@ #[cfg(test)] mod tests { - use std::{path::Path, sync::Arc}; + #[cfg(unix)] + use std::os::fd::AsRawFd; + use std::{io::Read, path::Path, sync::Arc}; use iroh::SecretKey; use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash}; use crate::{ access_policy::{AccessDecision, AccessPolicy}, - api::{CoreEvent, CoreEventSink, TransferMetadata}, - filesystem::{path_to_string, percent_decode_file_url_path, validated_relative_string}, + api::{CoreEvent, CoreEventSink, ShareSource, SourceKind, TransferMetadata}, + filesystem::{ + collect_import_files, path_to_string, percent_decode_file_url_path, + validated_relative_string, + }, repository::Repository, runtime::VnidropCore, secret::load_or_create_secret, @@ -75,6 +80,27 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn file_descriptor_source_duplicates_and_streams() { + let mut temp = tempfile::tempfile().unwrap(); + std::io::Write::write_all(&mut temp, b"fd-backed import").unwrap(); + std::io::Seek::rewind(&mut temp).unwrap(); + + let files = collect_import_files(vec![ShareSource { + kind: SourceKind::FileDescriptor, + value: temp.as_raw_fd().to_string(), + display_name: Some("from-fd.txt".to_string()), + is_directory: false, + }]) + .unwrap(); + + let mut imported = files.into_iter().next().unwrap().source.open().unwrap(); + let mut content = String::new(); + imported.read_to_string(&mut content).unwrap(); + assert_eq!(content, "fd-backed import"); + } + #[test] fn can_initialize_core() { let temp = tempfile::tempdir().unwrap(); diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.android.kt new file mode 100644 index 0000000..0bacfa8 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.android.kt @@ -0,0 +1,9 @@ +package com.vnidrop.app.core + +import uniffi.vnidrop.SourceKind + +internal actual suspend fun withPlatformPathAccess( + kind: SourceKind, + value: String, + block: suspend () -> T, +): T = block() diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index 3a12e08..69d659d 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -50,8 +50,7 @@ class CoreRepository( } suspend fun sharePath(path: String, transferName: String, senderName: String) = runCore { - val active = requireCore() - val result = active.shareFiles( + shareSources( sources = listOf( ShareSource( kind = SourceKind.PATH, @@ -60,14 +59,54 @@ class CoreRepository( isDirectory = false, ), ), - metadata = ShareMetadataInput( - transferId = nextTransferId(), - transferName = transferName.ifBlank { null }, - senderName = senderName.ifBlank { null }, - ), + transferName = transferName, + senderName = senderName, + ) + } + + suspend fun shareFileDescriptor( + fd: Int, + displayName: String, + transferName: String, + senderName: String, + ) = runCore { + // The fd is borrowed from platform code. Rust duplicates it before + // starting the import, so Android may close the ParcelFileDescriptor + // once this suspend call returns. + shareSources( + sources = listOf( + ShareSource( + kind = SourceKind.FILE_DESCRIPTOR, + value = fd.toString(), + displayName = displayName.ifBlank { "transfer" }, + isDirectory = false, + ), + ), + transferName = transferName, + senderName = senderName, + ) + } + + suspend fun shareSecurityScopedFileUrl( + fileUrl: String, + displayName: String, + transferName: String, + senderName: String, + ) = runCore { + // The iOS actual for withPlatformPathAccess starts and stops the + // security-scoped URL lease around this entire shareFiles call. + shareSources( + sources = listOf( + ShareSource( + kind = SourceKind.IOS_SECURITY_SCOPED_URL, + value = fileUrl, + displayName = displayName.ifBlank { fileUrl.substringAfterLast('/').ifBlank { "transfer" } }, + isDirectory = false, + ), + ), + transferName = transferName, + senderName = senderName, ) - _state.update { it.copy(lastShare = result, error = null) } - refreshStatus() } suspend fun inspectTicket(ticket: String) = runCore { @@ -80,6 +119,17 @@ class CoreRepository( refreshStatus() } + suspend fun receiveIntoSecurityScopedDirectory( + ticket: String, + outputDirectoryUrl: String, + receiverName: String, + ) = runCore { + withPlatformPathAccess(SourceKind.IOS_SECURITY_SCOPED_URL, outputDirectoryUrl) { + requireCore().receive(ticket, outputDirectoryUrl, receiverName.ifBlank { null }) + } + refreshStatus() + } + suspend fun cancel(transferId: ULong) = runCore { requireCore().cancelTransfer(transferId) refreshStatus() @@ -98,6 +148,39 @@ class CoreRepository( private fun requireCore(): VnidropCore = core ?: error("Initialize the core first.") + private suspend fun shareSources( + sources: List, + transferName: String, + senderName: String, + ) { + withPlatformPathAccess(sources) { + val result = requireCore().shareFiles( + sources = sources, + metadata = ShareMetadataInput( + transferId = nextTransferId(), + transferName = transferName.ifBlank { null }, + senderName = senderName.ifBlank { null }, + ), + ) + _state.update { it.copy(lastShare = result, error = null) } + } + refreshStatus() + } + + private suspend fun withPlatformPathAccess( + sources: List, + index: Int = 0, + block: suspend () -> T, + ): T { + if (index >= sources.size) { + return block() + } + val source = sources[index] + return withPlatformPathAccess(source.kind, source.value) { + withPlatformPathAccess(sources, index + 1, block) + } + } + private fun refreshStatus() { val status = core?.status() _state.update { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.kt new file mode 100644 index 0000000..068da8a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.kt @@ -0,0 +1,13 @@ +package com.vnidrop.app.core + +import uniffi.vnidrop.SourceKind + +// Platform file handles have different lifetime rules. Desktop paths need no +// extra work, Android fd sources are duplicated immediately by Rust, and iOS +// security-scoped URLs must remain leased while Rust performs the blocking +// import/export call. +internal expect suspend fun withPlatformPathAccess( + kind: SourceKind, + value: String, + block: suspend () -> T, +): T diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.ios.kt new file mode 100644 index 0000000..f29dd1b --- /dev/null +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.ios.kt @@ -0,0 +1,24 @@ +package com.vnidrop.app.core + +import platform.Foundation.NSURL +import uniffi.vnidrop.SourceKind + +internal actual suspend fun withPlatformPathAccess( + kind: SourceKind, + value: String, + block: suspend () -> T, +): T { + if (kind != SourceKind.IOS_SECURITY_SCOPED_URL) { + return block() + } + + val url = NSURL.URLWithString(value) ?: NSURL.fileURLWithPath(value) + val didStartAccess = url.startAccessingSecurityScopedResource() + return try { + block() + } finally { + if (didStartAccess) { + url.stopAccessingSecurityScopedResource() + } + } +} diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.jvm.kt new file mode 100644 index 0000000..0bacfa8 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.jvm.kt @@ -0,0 +1,9 @@ +package com.vnidrop.app.core + +import uniffi.vnidrop.SourceKind + +internal actual suspend fun withPlatformPathAccess( + kind: SourceKind, + value: String, + block: suspend () -> T, +): T = block()