diff --git a/crates/vnidrop/src/api.rs b/crates/vnidrop/src/api.rs index 9f12cb9..dc01589 100644 --- a/crates/vnidrop/src/api.rs +++ b/crates/vnidrop/src/api.rs @@ -20,6 +20,17 @@ pub trait CoreEventSink: Send + Sync { fn on_event(&self, event: CoreEvent); } +#[uniffi::export(with_foreign)] +pub trait ReceiveOutputSink: Send + Sync { + fn start_file(&self, relative_path: String) -> Result<(), crate::error::VnidropError>; + fn write_chunk( + &self, + relative_path: String, + bytes: Vec, + ) -> Result<(), crate::error::VnidropError>; + fn finish_file(&self, relative_path: String) -> Result<(), crate::error::VnidropError>; +} + #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct RuntimeStatus { pub endpoint_id: String, diff --git a/crates/vnidrop/src/lib.rs b/crates/vnidrop/src/lib.rs index 1de9c15..932ac74 100644 --- a/crates/vnidrop/src/lib.rs +++ b/crates/vnidrop/src/lib.rs @@ -13,9 +13,9 @@ mod ticket; mod util; pub use api::{ - CoreEvent, CoreEventSink, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult, - ShareSource, SourceKind, StoredTransfer, TicketInspection, TransferAccessMode, - TransferMetadata, + CoreEvent, CoreEventSink, ReceiveOutputSink, ReceiverRequest, RuntimeStatus, + ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, TicketInspection, + TransferAccessMode, TransferMetadata, }; pub use error::VnidropError; pub use runtime::VnidropCore; diff --git a/crates/vnidrop/src/runtime.rs b/crates/vnidrop/src/runtime.rs index 8d7c5a4..fa48753 100644 --- a/crates/vnidrop/src/runtime.rs +++ b/crates/vnidrop/src/runtime.rs @@ -32,16 +32,17 @@ use tokio::{ use crate::{ access_policy::{AccessDecision, AccessPolicy}, api::{ - CoreEvent, CoreEventSink, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult, - ShareSource, StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata, + CoreEvent, CoreEventSink, ReceiveOutputSink, ReceiverRequest, RuntimeStatus, + ShareMetadataInput, ShareResult, ShareSource, StoredTransfer, TicketInspection, + TransferAccessMode, TransferMetadata, }, approval::ApprovalService, error::VnidropError, event_hub::EventHub, filesystem::{ collect_import_files, default_collection_name, platform_path, - read_stream_from_blocking_reader, safe_output_path, wait_for_writer, - write_stream_to_blocking_writer, TransferImport, + read_stream_from_blocking_reader, safe_output_path, validated_relative_string, + wait_for_writer, write_stream_to_blocking_writer, TransferImport, }, handshake::{HandshakeResponse, HandshakeService}, logging::init_logging, @@ -82,6 +83,11 @@ struct CoreInner { shutdown_started: AtomicBool, } +enum ReceiveTarget { + Directory(PathBuf), + OutputSink(Arc), +} + #[uniffi::export] impl VnidropCore { #[uniffi::constructor] @@ -139,6 +145,33 @@ impl VnidropCore { .map_err(VnidropError::transfer) } + pub fn receive_with_output_sink( + &self, + ticket: String, + output_sink: Arc, + receiver_name: Option, + ) -> Result<(), VnidropError> { + if let Err(error) = + parse_transfer_ticket(&ticket).context("failed to parse transfer ticket") + { + self.runtime.block_on(async { + self.inner.emit_endpoint( + "error", + "invalid-ticket", + json!({ "reason": error.to_string() }), + ); + self.inner.event_hub.flush().await; + }); + return Err(VnidropError::ticket(error)); + } + self.runtime + .block_on( + self.inner + .receive_with_output_sink(ticket, output_sink, receiver_name), + ) + .map_err(VnidropError::transfer) + } + pub fn cancel_transfer(&self, transfer_id: u64) -> Result<(), VnidropError> { self.runtime .block_on(self.inner.cancel_transfer(transfer_id)) @@ -397,6 +430,30 @@ impl CoreInner { ticket: String, output_dir: PathBuf, receiver_name: Option, + ) -> Result<()> { + self.receive_to_target(ticket, ReceiveTarget::Directory(output_dir), receiver_name) + .await + } + + async fn receive_with_output_sink( + self: &Arc, + ticket: String, + output_sink: Arc, + receiver_name: Option, + ) -> Result<()> { + self.receive_to_target( + ticket, + ReceiveTarget::OutputSink(output_sink), + receiver_name, + ) + .await + } + + async fn receive_to_target( + self: &Arc, + ticket: String, + target: ReceiveTarget, + receiver_name: Option, ) -> Result<()> { let parsed = match parse_transfer_ticket(&ticket).context("failed to parse transfer ticket") { @@ -424,7 +481,7 @@ impl CoreInner { .insert(transfer_id, shutdown_tx); let result = tokio::select! { - result = self.receive_inner(transfer_id, parsed, output_dir, receiver_name) => result, + result = self.receive_inner(transfer_id, parsed, target, receiver_name) => result, _ = &mut shutdown_rx => Err(anyhow::anyhow!("transfer cancelled")), }; @@ -449,7 +506,7 @@ impl CoreInner { self: &Arc, transfer_id: u64, parsed: ParsedTransferTicket, - output_dir: PathBuf, + target: ReceiveTarget, receiver_name: Option, ) -> Result<()> { let metadata_json = @@ -490,7 +547,9 @@ impl CoreInner { .unwrap_or_default(), }) .await?; - tokio::fs::create_dir_all(&output_dir).await?; + if let ReceiveTarget::Directory(output_dir) = &target { + tokio::fs::create_dir_all(output_dir).await?; + } self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({})); if let Some(metadata) = &parsed.metadata { @@ -542,7 +601,7 @@ impl CoreInner { } let collection = Collection::load(hash_and_format.hash, self.store.as_ref()).await?; - self.export_collection(transfer_id, total_files, output_dir, collection) + self.export_collection(transfer_id, total_files, target, collection) .await?; self.repository .update_transfer_status(transfer_id, STATUS_DONE) @@ -795,24 +854,39 @@ impl CoreInner { &self, transfer_id: u64, total_files: u64, - output_dir: PathBuf, + target: ReceiveTarget, collection: Collection, ) -> Result<()> { for (i, (name, hash)) in collection.iter().enumerate() { - self.export_blob( - transfer_id, - total_files, - i as u64, - &output_dir, - name.as_ref(), - *hash, - ) - .await?; + match &target { + ReceiveTarget::Directory(output_dir) => { + self.export_blob_to_directory( + transfer_id, + total_files, + i as u64, + output_dir, + name.as_ref(), + *hash, + ) + .await?; + } + ReceiveTarget::OutputSink(output_sink) => { + self.export_blob_to_sink( + transfer_id, + total_files, + i as u64, + output_sink.as_ref(), + name.as_ref(), + *hash, + ) + .await?; + } + } } Ok(()) } - async fn export_blob( + async fn export_blob_to_directory( &self, transfer_id: u64, total_files: u64, @@ -873,6 +947,62 @@ impl CoreInner { Ok(()) } + async fn export_blob_to_sink( + &self, + transfer_id: u64, + total_files: u64, + current_file_index: u64, + output_sink: &dyn ReceiveOutputSink, + relative_path: &str, + hash: Hash, + ) -> Result<()> { + let relative_path = validated_relative_string(relative_path)?; + output_sink + .start_file(relative_path.clone()) + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let mut stream = self.store.export_ranges(hash, 0..u64::MAX).stream(); + let mut file_size = 0; + let mut exported = 0; + + while let Some(item) = stream.next().await { + match item { + ExportRangesItem::Size(size) => file_size = size, + ExportRangesItem::Data(leaf) => { + if leaf.offset != exported { + anyhow::bail!( + "export stream for {relative_path} yielded out-of-order data" + ); + } + exported += leaf.data.len() as u64; + output_sink + .write_chunk(relative_path.clone(), leaf.data.to_vec()) + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + self.emit_transfer( + transfer_id, + "receive", + "export", + "progress", + json!({ + "total_files": total_files, + "current_file_index": current_file_index, + "file_name": relative_path, + "file_size": file_size, + "exported": exported, + }), + ); + } + ExportRangesItem::Error(error) => { + anyhow::bail!("export failed for {relative_path}: {error}"); + } + } + } + + output_sink + .finish_file(relative_path) + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + Ok(()) + } + async fn spawn_provider_event_task(self: &Arc, mut rx: mpsc::Receiver) { let core = self.clone(); let task = tokio::spawn(async move { diff --git a/crates/vnidrop/tests/local_transfer.rs b/crates/vnidrop/tests/local_transfer.rs index cc249f3..914ed55 100644 --- a/crates/vnidrop/tests/local_transfer.rs +++ b/crates/vnidrop/tests/local_transfer.rs @@ -1,11 +1,12 @@ use std::{ + collections::HashMap, sync::{Arc, Mutex}, time::{Duration, Instant}, }; use vnidrop::{ - CoreEvent, CoreEventSink, ReceiverRequest, ShareMetadataInput, ShareSource, SourceKind, - VnidropCore, + CoreEvent, CoreEventSink, ReceiveOutputSink, ReceiverRequest, ShareMetadataInput, ShareSource, + SourceKind, VnidropCore, VnidropError, }; #[derive(Default)] @@ -25,6 +26,44 @@ impl RecordingSink { } } +#[derive(Default)] +struct MemoryOutputSink { + files: Mutex>>, + fail_writes: bool, +} + +impl ReceiveOutputSink for MemoryOutputSink { + fn start_file(&self, relative_path: String) -> Result<(), VnidropError> { + self.files.lock().unwrap().insert(relative_path, Vec::new()); + Ok(()) + } + + fn write_chunk(&self, relative_path: String, bytes: Vec) -> Result<(), VnidropError> { + if self.fail_writes { + return Err(VnidropError::Filesystem { + reason: "sink write failed".to_string(), + }); + } + self.files + .lock() + .unwrap() + .get_mut(&relative_path) + .expect("file was not started") + .extend(bytes); + Ok(()) + } + + fn finish_file(&self, _relative_path: String) -> Result<(), VnidropError> { + Ok(()) + } +} + +impl MemoryOutputSink { + fn file(&self, relative_path: &str) -> Vec { + self.files.lock().unwrap()[relative_path].clone() + } +} + fn wait_for_receiver_request(sender: &VnidropCore, transfer_id: u64) -> ReceiverRequest { let started = Instant::now(); loop { @@ -68,6 +107,31 @@ fn receive_with_response( handle.join().unwrap() } +fn receive_with_sink_response( + sender: &VnidropCore, + transfer_id: u64, + receiver: Arc, + ticket: String, + output_sink: Arc, + receiver_name: Option, + accepted: bool, +) -> Result<(), String> { + let handle = std::thread::spawn(move || { + receiver + .receive_with_output_sink(ticket, output_sink, receiver_name) + .map_err(|error| error.to_string()) + }); + let request = wait_for_receiver_request(sender, transfer_id); + sender + .respond_receiver_request( + request.id, + accepted, + (!accepted).then(|| "sender-refused".to_string()), + ) + .unwrap(); + handle.join().unwrap() +} + #[test] fn two_local_cores_transfer_file() { let sender_dir = tempfile::tempdir().unwrap(); @@ -201,6 +265,124 @@ fn two_local_cores_transfer_directory() { receiver.shutdown(); } +#[test] +fn output_sink_receive_exports_nested_files() { + let sender_dir = tempfile::tempdir().unwrap(); + let receiver_dir = tempfile::tempdir().unwrap(); + let source_root = sender_dir.path().join("photos"); + std::fs::create_dir_all(source_root.join("nested")).unwrap(); + std::fs::write(source_root.join("cover.txt"), b"cover").unwrap(); + std::fs::write(source_root.join("nested").join("inside.txt"), b"inside").unwrap(); + + let sender = VnidropCore::initialize( + sender_dir.path().join("core").to_string_lossy().to_string(), + Arc::new(RecordingSink::default()), + ) + .unwrap(); + let receiver = VnidropCore::initialize( + receiver_dir + .path() + .join("core") + .to_string_lossy() + .to_string(), + Arc::new(RecordingSink::default()), + ) + .unwrap(); + + let share = sender + .share_files( + vec![ShareSource { + kind: SourceKind::Path, + value: source_root.to_string_lossy().to_string(), + display_name: Some("photos".to_string()), + is_directory: true, + }], + ShareMetadataInput { + transfer_id: 18, + transfer_name: Some("photos".to_string()), + sender_name: Some("sender".to_string()), + }, + ) + .unwrap(); + let output_sink = Arc::new(MemoryOutputSink::default()); + + receive_with_sink_response( + &sender, + share.transfer_id, + receiver.clone(), + share.ticket, + output_sink.clone(), + Some("receiver".to_string()), + true, + ) + .unwrap(); + + assert_eq!(output_sink.file("photos/cover.txt"), b"cover"); + assert_eq!(output_sink.file("photos/nested/inside.txt"), b"inside"); + + sender.shutdown(); + receiver.shutdown(); +} + +#[test] +fn output_sink_receive_fails_when_sink_write_fails() { + let sender_dir = tempfile::tempdir().unwrap(); + let receiver_dir = tempfile::tempdir().unwrap(); + let source_path = sender_dir.path().join("hello.txt"); + std::fs::write(&source_path, b"hello").unwrap(); + + let sender = VnidropCore::initialize( + sender_dir.path().join("core").to_string_lossy().to_string(), + Arc::new(RecordingSink::default()), + ) + .unwrap(); + let receiver = VnidropCore::initialize( + receiver_dir + .path() + .join("core") + .to_string_lossy() + .to_string(), + Arc::new(RecordingSink::default()), + ) + .unwrap(); + + let share = sender + .share_files( + vec![ShareSource { + kind: SourceKind::Path, + value: source_path.to_string_lossy().to_string(), + display_name: Some("hello.txt".to_string()), + is_directory: false, + }], + ShareMetadataInput { + transfer_id: 19, + transfer_name: Some("hello".to_string()), + sender_name: Some("sender".to_string()), + }, + ) + .unwrap(); + let output_sink = Arc::new(MemoryOutputSink { + files: Mutex::new(HashMap::new()), + fail_writes: true, + }); + + let error = receive_with_sink_response( + &sender, + share.transfer_id, + receiver.clone(), + share.ticket, + output_sink, + Some("receiver".to_string()), + true, + ) + .unwrap_err(); + + assert!(error.contains("sink write failed")); + + sender.shutdown(); + receiver.shutdown(); +} + #[test] fn approval_required_denies_then_allows_receiver() { let sender_dir = tempfile::tempdir().unwrap(); diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8ac0a6f..36422dc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ androidx-appcompat = "1.7.1" androidx-core = "1.19.0" androidx-espresso = "3.7.0" androidx-lifecycle = "2.11.0-beta01" +androidx-datastore = "1.2.1" androidx-testExt = "1.3.0" composeMultiplatform = "1.11.1" gobley = "0.3.7" @@ -29,6 +30,8 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidx-datastore" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 98aadd7..664477c 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -47,6 +47,8 @@ kotlin { implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) + implementation(libs.androidx.datastore) + implementation(libs.androidx.datastore.preferences) implementation(libs.kotlinx.coroutinesCore) } commonTest.dependencies { diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt index 34faa26..5ed1e3b 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt @@ -5,14 +5,19 @@ import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.BatteryManager import android.os.Build +import android.os.Environment import java.net.NetworkInterface class AndroidPlatform : Platform { override val name: String = "Android ${Build.VERSION.SDK_INT}" override val defaultCoreDataDir: String = - System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop" + AndroidPlatformContextHolder.context?.filesDir?.resolve("vnidrop")?.absolutePath + ?: (System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop") override val defaultReceiveDir: String = - System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop-receive" + AndroidPlatformContextHolder.context + ?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) + ?.absolutePath + ?: (System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop-receive") override val deviceInfo: DeviceInfo = DeviceInfo( deviceName = Build.DEVICE, deviceModel = listOf(Build.MANUFACTURER, Build.MODEL) diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt index 62b4a9e..155b2cc 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt @@ -1,6 +1,7 @@ package com.vnidrop.app.core import android.content.Context +import android.content.Intent import android.net.Uri import android.provider.OpenableColumns import androidx.activity.compose.rememberLauncherForActivityResult @@ -29,6 +30,38 @@ actual fun rememberShareFilePicker( } } +@Composable +actual fun rememberReceiveFolderPicker( + onFolderPicked: (ReceiveFolder) -> Unit, + onError: (String) -> Unit, +): ReceiveFolderPicker { + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + if (uri != null) { + runCatching { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + } + onFolderPicked( + ReceiveFolder( + kind = ReceiveFolderKind.AndroidTreeUri, + value = uri.toString(), + displayName = uri.lastPathSegment ?: "Downloads", + ), + ) + } + } + return remember(launcher) { + object : ReceiveFolderPicker { + override fun pickFolder() { + launcher.launch(null) + } + } + } +} + actual suspend fun sharePickedFile( repository: CoreRepository, file: PickedShareFile, diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt new file mode 100644 index 0000000..3da4407 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt @@ -0,0 +1,132 @@ +package com.vnidrop.app.core + +import android.content.Context +import android.net.Uri +import android.os.Environment +import android.provider.DocumentsContract +import androidx.core.net.toUri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import uniffi.vnidrop.ReceiveOutputSink +import java.io.OutputStream + +@Composable +actual fun rememberFileSystemService(): FileSystemService { + val context = LocalContext.current.applicationContext + return remember(context) { AndroidFileSystemService(context) } +} + +private class AndroidFileSystemService( + private val context: Context, +) : FileSystemService { + override fun defaultReceiveFolder(): ReceiveFolder { + val path = context + .getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) + ?.absolutePath + ?: (System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop-receive") + return ReceiveFolder( + kind = ReceiveFolderKind.FileSystemPath, + value = path, + displayName = "Downloads", + ) + } + + override suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus = + when (folder.kind) { + ReceiveFolderKind.FileSystemPath -> validatePath(folder.value) + ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value) + ReceiveFolderKind.IosSecurityScopedUrl -> FolderAccessStatus.Unavailable + } + + override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? { + if (folder.kind != ReceiveFolderKind.AndroidTreeUri) return null + return AndroidTreeReceiveOutputSink(context, folder.value.toUri()) + } + + private fun validatePath(path: String): FolderAccessStatus = + runCatching { + val directory = java.io.File(path) + if (!directory.exists()) directory.mkdirs() + if (directory.isDirectory && directory.canWrite()) FolderAccessStatus.Writable else FolderAccessStatus.Unavailable + }.getOrDefault(FolderAccessStatus.Unavailable) + + private fun validateTreeUri(value: String): FolderAccessStatus { + val uri = Uri.parse(value) + val hasPermission = context.contentResolver.persistedUriPermissions.any { permission -> + permission.uri == uri && permission.isWritePermission + } + if (!hasPermission) return FolderAccessStatus.PermissionRequired + return runCatching { + val probe = AndroidTreeReceiveOutputSink(context, uri) + val probeName = ".vnidrop-write-test" + probe.startFile(probeName) + probe.writeChunk(probeName, byteArrayOf()) + probe.finishFile(probeName) + FolderAccessStatus.Writable + }.getOrDefault(FolderAccessStatus.Unavailable) + } +} + +private class AndroidTreeReceiveOutputSink( + private val context: Context, + private val treeUri: Uri, +) : ReceiveOutputSink { + private val streams = mutableMapOf() + + override fun startFile(relativePath: String) { + streams[relativePath]?.close() + val documentUri = createDocument(relativePath) + val stream = context.contentResolver.openOutputStream(documentUri, "w") + ?: error("Could not open output stream for $relativePath") + streams[relativePath] = stream + } + + override fun writeChunk(relativePath: String, bytes: ByteArray) { + val stream = streams[relativePath] ?: error("Output stream is not open for $relativePath") + stream.write(bytes) + } + + override fun finishFile(relativePath: String) { + streams.remove(relativePath)?.close() + } + + private fun createDocument(relativePath: String): Uri { + val parts = relativePath.split('/').filter { it.isNotBlank() } + require(parts.isNotEmpty()) { "relative path must not be empty" } + var parent = DocumentsContract.buildDocumentUriUsingTree( + treeUri, + DocumentsContract.getTreeDocumentId(treeUri), + ) + parts.dropLast(1).forEach { name -> + parent = findChild(parent, name) + ?: DocumentsContract.createDocument(context.contentResolver, parent, DocumentsContract.Document.MIME_TYPE_DIR, name) + ?: error("Could not create directory $name") + } + return DocumentsContract.createDocument(context.contentResolver, parent, "application/octet-stream", parts.last()) + ?: error("Could not create file ${parts.last()}") + } + + private fun findChild(parent: Uri, name: String): Uri? { + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( + treeUri, + DocumentsContract.getDocumentId(parent), + ) + context.contentResolver.query( + childrenUri, + arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME), + null, + null, + null, + )?.use { cursor -> + val idIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + val nameIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + while (cursor.moveToNext()) { + if (cursor.getString(nameIndex) == name) { + return DocumentsContract.buildDocumentUriUsingTree(treeUri, cursor.getString(idIndex)) + } + } + } + return null + } +} diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 0e26a3e..4b661ec 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -3,7 +3,10 @@ Receive Settings Send - Create a VniDrop ticket and approve receivers when required. + Send files to another device when you are ready. + No active transfers + Create a transfer when you are ready. You will pick what to send, review the details, and share it with another device. + Create new transfer Source Select a file to start a share. The app keeps bytes in Rust and platform file handles. Select file @@ -11,9 +14,9 @@ Transfer details Transfer name Sender name - Create share ticket - Creating ticket... - Share ticket + Create share + Creating share... + Share details Receiver requests Copy Use locally @@ -35,6 +38,15 @@ Configure the local node and app appearance. Node Appearance + Preferences + Username + Receive folder + Choose folder + Reset default + Writable + Permission required + Unavailable + Checking folder... Display mode System Dark mode diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index 36b5770..994cf01 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -10,8 +10,13 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.vnidrop.app.core.rememberFileSystemService +import com.vnidrop.app.core.rememberReceiveFolderPicker import com.vnidrop.app.core.rememberShareFilePicker import com.vnidrop.app.logging.AppLogger +import com.vnidrop.app.preferences.AppPreferencesDefaults +import com.vnidrop.app.preferences.AppPreferencesRepository +import com.vnidrop.app.preferences.createAppPreferencesDataStore import com.vnidrop.app.platform.PlatformSystemAppearance import com.vnidrop.app.ui.navigation.AppDestination import com.vnidrop.app.ui.screens.ReceiveScreen @@ -19,6 +24,7 @@ import com.vnidrop.app.ui.screens.SendScreen import com.vnidrop.app.ui.screens.SettingsScreen import com.vnidrop.app.ui.shell.AppShell import com.vnidrop.app.ui.state.windowClassFor +import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.VniDropTheme import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme @@ -26,11 +32,23 @@ import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme @Preview fun App() { val platform = remember { getPlatform() } + val fileSystemService = rememberFileSystemService() + val preferencesRepository = remember(platform.defaultCoreDataDir) { + AppPreferencesRepository( + dataStore = createAppPreferencesDataStore(platform.defaultCoreDataDir), + defaults = AppPreferencesDefaults( + username = platform.deviceInfo.deviceName?.takeIf { it.isNotBlank() } ?: "Receiver", + receiveFolder = fileSystemService.defaultReceiveFolder(), + themeMode = ThemeMode.System, + ), + ) + } val viewModel = viewModel { VniDropAppViewModel( appDataDir = platform.defaultCoreDataDir, - defaultReceiveDir = platform.defaultReceiveDir, platformName = platform.name, + preferencesRepository = preferencesRepository, + fileSystemService = fileSystemService, ) } val state by viewModel.state.collectAsStateWithLifecycle() @@ -44,6 +62,14 @@ fun App() { viewModel.onEvent(VniDropAppEvent.ShareFilePickFailed(error)) }, ) + val receiveFolderPicker = rememberReceiveFolderPicker( + onFolderPicked = { folder -> + viewModel.onEvent(VniDropAppEvent.ReceiveFolderPicked(folder)) + }, + onError = { error -> + viewModel.onEvent(VniDropAppEvent.ReceiveFolderPickFailed(error)) + }, + ) LaunchedEffect(viewModel) { viewModel.effectFlow.collect { effect -> @@ -52,6 +78,10 @@ fun App() { AppLogger.info("file-picker", "open share file picker") picker.pickFile() } + VniDropAppEffect.OpenReceiveFolderPicker -> { + AppLogger.info("file-picker", "open receive folder picker") + receiveFolderPicker.pickFolder() + } is VniDropAppEffect.CopyTicket -> { AppLogger.info("send", "ticket copied") clipboard.setText(AnnotatedString(effect.ticket)) @@ -78,19 +108,22 @@ fun App() { AppDestination.Send -> SendScreen( coreState = coreState, sendState = state.send, + windowClass = windowClass, onEvent = viewModel::onEvent, ) AppDestination.Receive -> ReceiveScreen( coreState = coreState, receiveState = state.receive, + preferencesState = state.preferences, onEvent = viewModel::onEvent, ) AppDestination.Settings -> SettingsScreen( deviceInfo = platform.deviceInfo, coreState = coreState, themeMode = state.app.themeMode, + preferencesState = state.preferences, windowClass = windowClass, - onThemeModeChange = { viewModel.onEvent(VniDropAppEvent.ThemeModeChanged(it)) }, + onEvent = viewModel::onEvent, ) } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/VniDropAppViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/VniDropAppViewModel.kt index 84746e9..2990c7e 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/VniDropAppViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/VniDropAppViewModel.kt @@ -4,11 +4,18 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vnidrop.app.core.CoreRepository import com.vnidrop.app.core.CoreUiState +import com.vnidrop.app.core.FileSystemService +import com.vnidrop.app.core.FolderAccessStatus import com.vnidrop.app.core.PickedShareFile +import com.vnidrop.app.core.ReceiveFolder +import com.vnidrop.app.core.ReceiveFolderKind import com.vnidrop.app.core.sharePickedFile import com.vnidrop.app.logging.AppLogger +import com.vnidrop.app.preferences.AppPreferences +import com.vnidrop.app.preferences.AppPreferencesRepository import com.vnidrop.app.ui.navigation.AppDestination import com.vnidrop.app.ui.state.AppUiState +import com.vnidrop.app.ui.state.PreferencesUiState import com.vnidrop.app.ui.state.ReceiveUiState import com.vnidrop.app.ui.state.SendUiState import com.vnidrop.app.ui.theme.ThemeMode @@ -21,6 +28,7 @@ import kotlinx.coroutines.launch data class VniDropAppState( val app: AppUiState = AppUiState(), + val preferences: PreferencesUiState = PreferencesUiState(), val send: SendUiState = SendUiState(), val receive: ReceiveUiState = ReceiveUiState(), ) @@ -28,6 +36,11 @@ data class VniDropAppState( sealed interface VniDropAppEvent { data class DestinationSelected(val destination: AppDestination) : VniDropAppEvent data class ThemeModeChanged(val mode: ThemeMode) : VniDropAppEvent + data class UsernameChanged(val value: String) : VniDropAppEvent + data object ChooseReceiveFolderClicked : VniDropAppEvent + data class ReceiveFolderPicked(val folder: ReceiveFolder) : VniDropAppEvent + data class ReceiveFolderPickFailed(val reason: String) : VniDropAppEvent + data object ResetReceiveFolderClicked : VniDropAppEvent data object SelectFileClicked : VniDropAppEvent data class ShareFilePicked(val file: PickedShareFile) : VniDropAppEvent data class ShareFilePickFailed(val reason: String) : VniDropAppEvent @@ -48,16 +61,18 @@ sealed interface VniDropAppEvent { sealed interface VniDropAppEffect { data object OpenShareFilePicker : VniDropAppEffect + data object OpenReceiveFolderPicker : VniDropAppEffect data class CopyTicket(val ticket: String) : VniDropAppEffect } class VniDropAppViewModel( appDataDir: String, - defaultReceiveDir: String, platformName: String, + private val preferencesRepository: AppPreferencesRepository, + private val fileSystemService: FileSystemService, private val repository: CoreRepository = CoreRepository(), ) : ViewModel() { - private val _state = MutableStateFlow(VniDropAppState(receive = ReceiveUiState(outputDirectory = defaultReceiveDir))) + private val _state = MutableStateFlow(VniDropAppState()) val state: StateFlow = _state val coreState: StateFlow = repository.state @@ -73,12 +88,23 @@ class VniDropAppViewModel( viewModelScope.launch { repository.initialize(appDataDir) } + viewModelScope.launch { + preferencesRepository.preferences.collect { preferences -> + applyPreferences(preferences) + validateReceiveFolder(preferences.receiveFolder) + } + } } fun onEvent(event: VniDropAppEvent) { when (event) { is VniDropAppEvent.DestinationSelected -> updateAppState { copy(destination = event.destination) } is VniDropAppEvent.ThemeModeChanged -> setThemeMode(event.mode) + is VniDropAppEvent.UsernameChanged -> setUsername(event.value) + VniDropAppEvent.ChooseReceiveFolderClicked -> sendEffect(VniDropAppEffect.OpenReceiveFolderPicker) + is VniDropAppEvent.ReceiveFolderPicked -> setReceiveFolder(event.folder) + is VniDropAppEvent.ReceiveFolderPickFailed -> setFilePickerError(event.reason) + VniDropAppEvent.ResetReceiveFolderClicked -> resetReceiveFolder() VniDropAppEvent.SelectFileClicked -> sendEffect(VniDropAppEffect.OpenShareFilePicker) is VniDropAppEvent.ShareFilePicked -> setSelectedFile(event.file) is VniDropAppEvent.ShareFilePickFailed -> setFilePickerError(event.reason) @@ -101,6 +127,27 @@ class VniDropAppViewModel( private fun setThemeMode(mode: ThemeMode) { AppLogger.info("appearance", "theme mode changed", mapOf("mode" to mode.name)) updateAppState { copy(themeMode = mode) } + viewModelScope.launch { + preferencesRepository.setThemeMode(mode) + } + } + + private fun setUsername(username: String) { + viewModelScope.launch { + preferencesRepository.setUsername(username) + } + } + + private fun setReceiveFolder(folder: ReceiveFolder) { + viewModelScope.launch { + preferencesRepository.setReceiveFolder(folder) + } + } + + private fun resetReceiveFolder() { + viewModelScope.launch { + preferencesRepository.resetReceiveFolder() + } } private fun setSelectedFile(file: PickedShareFile) { @@ -179,19 +226,72 @@ class VniDropAppViewModel( private fun receive() { val receiveState = state.value.receive - if (!receiveState.canReceive(coreState.value.isInitialized)) return + val preferences = state.value.preferences + if (!receiveState.canReceive(coreState.value.isInitialized) || !preferences.canReceiveIntoFolder) return viewModelScope.launch { AppLogger.info("receive", "receive requested") updateReceiveState { copy(isReceiving = true) } try { - repository.receive(receiveState.ticket, receiveState.outputDirectory, receiveState.receiverName) + receiveIntoFolder(receiveState, preferences.receiveFolder) } finally { updateReceiveState { copy(isReceiving = false) } } } } + private suspend fun receiveIntoFolder(receiveState: ReceiveUiState, folder: ReceiveFolder) { + val outputSink = fileSystemService.createReceiveOutputSink(folder) + when { + outputSink != null -> repository.receiveWithOutputSink(receiveState.ticket, outputSink, receiveState.receiverName) + folder.kind == ReceiveFolderKind.IosSecurityScopedUrl -> { + repository.receiveIntoSecurityScopedDirectory(receiveState.ticket, folder.value, receiveState.receiverName) + } + else -> repository.receive(receiveState.ticket, folder.value, receiveState.receiverName) + } + } + + private fun applyPreferences(preferences: AppPreferences) { + _state.update { current -> + val previousUsername = current.preferences.username + current.copy( + app = current.app.copy(themeMode = preferences.themeMode), + preferences = current.preferences.copy( + username = preferences.username, + receiveFolder = preferences.receiveFolder, + ), + send = current.send.withDefaultName( + currentValue = current.send.senderName, + previousDefault = previousUsername, + nextDefault = preferences.username, + update = { copy(senderName = it) }, + ), + receive = current.receive + .withDefaultName( + currentValue = current.receive.receiverName, + previousDefault = previousUsername, + nextDefault = preferences.username, + update = { copy(receiverName = it) }, + ) + .copy(outputDirectory = preferences.receiveFolder.value), + ) + } + } + + private suspend fun validateReceiveFolder(folder: ReceiveFolder) { + updatePreferencesState { copy(isValidatingFolder = true) } + val status = fileSystemService.validateReceiveFolder(folder) + updatePreferencesState { + copy( + folderAccessStatus = status, + isValidatingFolder = false, + ) + } + if (status != FolderAccessStatus.Writable) { + repository.setError("Receive folder is not writable.") + } + } + private fun sendEffect(effect: VniDropAppEffect) { viewModelScope.launch { effects.send(effect) @@ -218,8 +318,27 @@ class VniDropAppViewModel( if (next == current.receive) current else current.copy(receive = next) } } + + private fun updatePreferencesState(reducer: PreferencesUiState.() -> PreferencesUiState) { + _state.update { current -> + val next = current.preferences.reducer() + if (next == current.preferences) current else current.copy(preferences = next) + } + } } +private fun T.withDefaultName( + currentValue: String, + previousDefault: String, + nextDefault: String, + update: T.(String) -> T, +): T = + if (currentValue.isBlank() || currentValue == previousDefault) { + update(nextDefault) + } else { + this + } + private fun SendUiState.withSelectedFile(file: PickedShareFile): SendUiState = copy( selectedSource = file.value, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index acb344f..95355ab 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -11,6 +11,7 @@ import kotlin.random.Random import uniffi.vnidrop.CoreEvent import uniffi.vnidrop.CoreEventSink import uniffi.vnidrop.ReceiverRequest +import uniffi.vnidrop.ReceiveOutputSink import uniffi.vnidrop.ShareMetadataInput import uniffi.vnidrop.ShareResult import uniffi.vnidrop.ShareSource @@ -126,6 +127,12 @@ class CoreRepository( loadTransfers() } + suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = runCore { + requireCore().receiveWithOutputSink(ticket, outputSink, receiverName.ifBlank { null }) + refreshStatus() + loadTransfers() + } + suspend fun receiveIntoSecurityScopedDirectory( ticket: String, outputDirectoryUrl: String, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt index bce6356..cc7652c 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt @@ -11,12 +11,22 @@ interface ShareFilePicker { fun pickFile() } +interface ReceiveFolderPicker { + fun pickFolder() +} + @Composable expect fun rememberShareFilePicker( onFilePicked: (PickedShareFile) -> Unit, onError: (String) -> Unit, ): ShareFilePicker +@Composable +expect fun rememberReceiveFolderPicker( + onFolderPicked: (ReceiveFolder) -> Unit, + onError: (String) -> Unit, +): ReceiveFolderPicker + expect suspend fun sharePickedFile( repository: CoreRepository, file: PickedShareFile, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt new file mode 100644 index 0000000..7906f7f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt @@ -0,0 +1,34 @@ +package com.vnidrop.app.core + +import androidx.compose.runtime.Composable +import uniffi.vnidrop.ReceiveOutputSink + +enum class ReceiveFolderKind { + FileSystemPath, + AndroidTreeUri, + IosSecurityScopedUrl, +} + +data class ReceiveFolder( + val kind: ReceiveFolderKind, + val value: String, + val displayName: String, +) + +enum class FolderAccessStatus { + Writable, + PermissionRequired, + Unavailable, +} + +interface FileSystemService { + fun defaultReceiveFolder(): ReceiveFolder + suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus + fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? +} + +@Composable +expect fun rememberFileSystemService(): FileSystemService + +fun ReceiveFolder.isFileSystemPath(): Boolean = + kind == ReceiveFolderKind.FileSystemPath diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt new file mode 100644 index 0000000..fed6f77 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt @@ -0,0 +1,94 @@ +package com.vnidrop.app.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vnidrop.app.core.ReceiveFolder +import com.vnidrop.app.core.ReceiveFolderKind +import com.vnidrop.app.ui.theme.ThemeMode +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import okio.Path.Companion.toPath + +data class AppPreferences( + val username: String, + val receiveFolder: ReceiveFolder, + val themeMode: ThemeMode, +) + +class AppPreferencesDefaults( + val username: String, + val receiveFolder: ReceiveFolder, + val themeMode: ThemeMode, +) + +class AppPreferencesRepository( + private val dataStore: DataStore, + private val defaults: AppPreferencesDefaults, +) { + val preferences: Flow = dataStore.data + .catch { emit(emptyPreferences()) } + .map { prefs -> + AppPreferences( + username = prefs[PreferenceKeys.Username]?.takeIf { it.isNotBlank() } ?: defaults.username, + receiveFolder = ReceiveFolder( + kind = prefs[PreferenceKeys.ReceiveFolderKind]?.let { receiveFolderKindOrNull(it) } + ?: defaults.receiveFolder.kind, + value = prefs[PreferenceKeys.ReceiveFolderValue]?.takeIf { it.isNotBlank() } + ?: defaults.receiveFolder.value, + displayName = prefs[PreferenceKeys.ReceiveFolderDisplayName]?.takeIf { it.isNotBlank() } + ?: defaults.receiveFolder.displayName, + ), + themeMode = prefs[PreferenceKeys.ThemeMode]?.let { themeModeOrNull(it) } ?: defaults.themeMode, + ) + } + + suspend fun setUsername(username: String) { + dataStore.edit { prefs -> + prefs[PreferenceKeys.Username] = username.trim() + } + } + + suspend fun setReceiveFolder(folder: ReceiveFolder) { + dataStore.edit { prefs -> + prefs[PreferenceKeys.ReceiveFolderKind] = folder.kind.name + prefs[PreferenceKeys.ReceiveFolderValue] = folder.value + prefs[PreferenceKeys.ReceiveFolderDisplayName] = folder.displayName + } + } + + suspend fun resetReceiveFolder() { + setReceiveFolder(defaults.receiveFolder) + } + + suspend fun setThemeMode(mode: ThemeMode) { + dataStore.edit { prefs -> + prefs[PreferenceKeys.ThemeMode] = mode.name + } + } +} + +fun createAppPreferencesDataStore(appDataDir: String): DataStore = + PreferenceDataStoreFactory.createWithPath( + produceFile = { "$appDataDir/$AppPreferencesFileName".toPath() }, + ) + +private object PreferenceKeys { + val Username = stringPreferencesKey("username") + val ReceiveFolderKind = stringPreferencesKey("receive_folder_kind") + val ReceiveFolderValue = stringPreferencesKey("receive_folder_value") + val ReceiveFolderDisplayName = stringPreferencesKey("receive_folder_display_name") + val ThemeMode = stringPreferencesKey("theme_mode") +} + +private fun receiveFolderKindOrNull(raw: String): ReceiveFolderKind? = + runCatching { ReceiveFolderKind.valueOf(raw) }.getOrNull() + +private fun themeModeOrNull(raw: String): ThemeMode? = + runCatching { ThemeMode.valueOf(raw) }.getOrNull() + +private const val AppPreferencesFileName = "app_preferences.preferences_pb" diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ReceiveScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ReceiveScreen.kt index eb2adef..01fb391 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ReceiveScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ReceiveScreen.kt @@ -7,10 +7,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp import com.vnidrop.app.VniDropAppEvent import com.vnidrop.app.core.CoreUiState +import com.vnidrop.app.core.FolderAccessStatus import com.vnidrop.app.ui.components.AppCard import com.vnidrop.app.ui.components.Field +import com.vnidrop.app.ui.components.MetadataRow import com.vnidrop.app.ui.components.PrimaryButton import com.vnidrop.app.ui.components.SecondaryButton +import com.vnidrop.app.ui.state.PreferencesUiState import com.vnidrop.app.ui.state.ReceiveUiState import org.jetbrains.compose.resources.stringResource import vnidrop.shared.generated.resources.Res @@ -20,6 +23,11 @@ import vnidrop.shared.generated.resources.button_receiving import vnidrop.shared.generated.resources.field_output_directory import vnidrop.shared.generated.resources.field_receiver_name import vnidrop.shared.generated.resources.field_ticket +import vnidrop.shared.generated.resources.folder_status_permission_required +import vnidrop.shared.generated.resources.folder_status_unavailable +import vnidrop.shared.generated.resources.folder_status_validating +import vnidrop.shared.generated.resources.folder_status_writable +import vnidrop.shared.generated.resources.metadata_status import vnidrop.shared.generated.resources.receive_subtitle import vnidrop.shared.generated.resources.receive_title import vnidrop.shared.generated.resources.ticket_card_title @@ -28,6 +36,7 @@ import vnidrop.shared.generated.resources.ticket_card_title fun ReceiveScreen( coreState: CoreUiState, receiveState: ReceiveUiState, + preferencesState: PreferencesUiState, onEvent: (VniDropAppEvent) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { @@ -40,10 +49,13 @@ fun ReceiveScreen( label = stringResource(Res.string.field_ticket), minLines = 4, ) - Field( - value = receiveState.outputDirectory, - onValueChange = { onEvent(VniDropAppEvent.OutputDirectoryChanged(it)) }, + MetadataRow( label = stringResource(Res.string.field_output_directory), + value = preferencesState.receiveFolder.displayName.ifBlank { preferencesState.receiveFolder.value }, + ) + MetadataRow( + label = stringResource(Res.string.metadata_status), + value = preferencesState.folderAccessStatus.displayName(preferencesState.isValidatingFolder), ) Field( value = receiveState.receiverName, @@ -59,7 +71,7 @@ fun ReceiveScreen( PrimaryButton( text = if (receiveState.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive), onClick = { onEvent(VniDropAppEvent.ReceiveClicked) }, - enabled = receiveState.canReceive(coreState.isInitialized), + enabled = receiveState.canReceive(coreState.isInitialized) && preferencesState.canReceiveIntoFolder, ) } } @@ -67,3 +79,15 @@ fun ReceiveScreen( ProgressSection(coreState) } } + +@Composable +private fun FolderAccessStatus.displayName(isValidating: Boolean): String = + if (isValidating) { + stringResource(Res.string.folder_status_validating) + } else { + when (this) { + FolderAccessStatus.Writable -> stringResource(Res.string.folder_status_writable) + FolderAccessStatus.PermissionRequired -> stringResource(Res.string.folder_status_permission_required) + FolderAccessStatus.Unavailable -> stringResource(Res.string.folder_status_unavailable) + } + } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendEmptyState.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendEmptyState.kt new file mode 100644 index 0000000..79fa345 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendEmptyState.kt @@ -0,0 +1,77 @@ +package com.vnidrop.app.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.vnidrop.app.core.CoreUiState +import com.vnidrop.app.ui.components.PrimaryButton +import com.vnidrop.app.ui.navigation.VniDropIcons +import com.vnidrop.app.ui.state.SendUiState +import com.vnidrop.app.ui.state.WindowClass +import com.vnidrop.app.ui.state.isActiveTransfer +import com.vnidrop.app.ui.theme.LocalVniDropColors +import org.jetbrains.compose.resources.stringResource +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.button_create_new_transfer +import vnidrop.shared.generated.resources.send_empty_body +import vnidrop.shared.generated.resources.send_empty_title + +@Composable +internal fun SendEmptyState(windowClass: WindowClass) { + val colors = LocalVniDropColors.current + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = if (windowClass == WindowClass.Phone) 540.dp else 420.dp) + .padding(horizontal = 24.dp, vertical = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = VniDropIcons.Send, + contentDescription = null, + modifier = Modifier.size(96.dp), + tint = colors.brandDefault, + ) + Text( + text = stringResource(Res.string.send_empty_title), + modifier = Modifier.padding(top = 24.dp), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(Res.string.send_empty_body), + modifier = Modifier + .padding(top = 10.dp) + .widthIn(max = 520.dp), + color = colors.foregroundLighter, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + PrimaryButton( + text = stringResource(Res.string.button_create_new_transfer), + onClick = {}, + modifier = Modifier.padding(top = 24.dp), + ) + } +} + +internal fun SendUiState.shouldShowEmptyState(coreState: CoreUiState): Boolean = + !hasSelectedSource && + !isSharing && + coreState.lastShare == null && + coreState.transfers.none { it.direction == "send" && it.isActiveTransfer() } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendScreen.kt index 9c41ccf..878175e 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendScreen.kt @@ -17,6 +17,7 @@ import com.vnidrop.app.ui.components.PrimaryButton import com.vnidrop.app.ui.components.SecondaryButton import com.vnidrop.app.ui.components.StatusPill import com.vnidrop.app.ui.state.SendUiState +import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.state.formatBytes import org.jetbrains.compose.resources.stringResource import uniffi.vnidrop.ReceiverRequest @@ -25,8 +26,8 @@ import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.button_approve import vnidrop.shared.generated.resources.button_clear import vnidrop.shared.generated.resources.button_copy -import vnidrop.shared.generated.resources.button_create_share_ticket -import vnidrop.shared.generated.resources.button_creating_ticket +import vnidrop.shared.generated.resources.button_create_share +import vnidrop.shared.generated.resources.button_creating_share import vnidrop.shared.generated.resources.button_refresh import vnidrop.shared.generated.resources.button_refuse import vnidrop.shared.generated.resources.button_select_file @@ -41,7 +42,7 @@ import vnidrop.shared.generated.resources.receiver_requests_title import vnidrop.shared.generated.resources.send_source_empty import vnidrop.shared.generated.resources.send_subtitle import vnidrop.shared.generated.resources.send_title -import vnidrop.shared.generated.resources.share_ticket_title +import vnidrop.shared.generated.resources.share_details_title import vnidrop.shared.generated.resources.source_title import vnidrop.shared.generated.resources.transfer_details_title @@ -49,28 +50,35 @@ import vnidrop.shared.generated.resources.transfer_details_title fun SendScreen( coreState: CoreUiState, sendState: SendUiState, + windowClass: WindowClass, onEvent: (VniDropAppEvent) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { - ScreenHeader(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle)) + if (!sendState.shouldShowEmptyState(coreState)) { + ScreenHeader(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle)) + } ErrorSection(coreState) - SendSourceCard( - sendState = sendState, - onEvent = onEvent, - ) - SendDetailsCard( - coreState = coreState, - sendState = sendState, - onEvent = onEvent, - ) - coreState.lastShare?.let { share -> - ShareResultCard( - share = share, - requests = coreState.receiverRequests, + if (sendState.shouldShowEmptyState(coreState)) { + SendEmptyState(windowClass = windowClass) + } else { + SendSourceCard( + sendState = sendState, onEvent = onEvent, ) + SendDetailsCard( + coreState = coreState, + sendState = sendState, + onEvent = onEvent, + ) + coreState.lastShare?.let { share -> + ShareResultCard( + share = share, + requests = coreState.receiverRequests, + onEvent = onEvent, + ) + } + ProgressSection(coreState) } - ProgressSection(coreState) } } @@ -118,7 +126,7 @@ private fun SendDetailsCard( label = stringResource(Res.string.field_sender_name), ) PrimaryButton( - text = if (sendState.isSharing) stringResource(Res.string.button_creating_ticket) else stringResource(Res.string.button_create_share_ticket), + text = if (sendState.isSharing) stringResource(Res.string.button_creating_share) else stringResource(Res.string.button_create_share), onClick = { onEvent(VniDropAppEvent.CreateShareClicked) }, enabled = sendState.canCreateShare(coreState.isInitialized), ) @@ -131,7 +139,7 @@ private fun ShareResultCard( requests: List, onEvent: (VniDropAppEvent) -> Unit, ) { - AppCard(title = stringResource(Res.string.share_ticket_title), trailing = { + AppCard(title = stringResource(Res.string.share_details_title), trailing = { StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand) }) { MetadataRow(stringResource(Res.string.metadata_transfer), share.transferName) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SettingsScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SettingsScreen.kt index 88c1b67..1aa54ab 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SettingsScreen.kt @@ -39,7 +39,13 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vnidrop.app.DeviceInfo +import com.vnidrop.app.VniDropAppEvent import com.vnidrop.app.core.CoreUiState +import com.vnidrop.app.core.FolderAccessStatus +import com.vnidrop.app.ui.components.Field +import com.vnidrop.app.ui.components.PrimaryButton +import com.vnidrop.app.ui.components.SecondaryButton +import com.vnidrop.app.ui.state.PreferencesUiState import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.theme.LocalVniDropColors import com.vnidrop.app.ui.theme.ThemeMode @@ -55,9 +61,18 @@ import vnidrop.shared.generated.resources.appearance_mode_title import vnidrop.shared.generated.resources.appearance_system_mode import vnidrop.shared.generated.resources.appearance_title import vnidrop.shared.generated.resources.battery_level_title +import vnidrop.shared.generated.resources.button_choose_folder import vnidrop.shared.generated.resources.core_status_ready import vnidrop.shared.generated.resources.device_model_title import vnidrop.shared.generated.resources.device_name_title +import vnidrop.shared.generated.resources.folder_status_permission_required +import vnidrop.shared.generated.resources.folder_status_unavailable +import vnidrop.shared.generated.resources.folder_status_validating +import vnidrop.shared.generated.resources.folder_status_writable +import vnidrop.shared.generated.resources.preferences_receive_folder_title +import vnidrop.shared.generated.resources.preferences_title +import vnidrop.shared.generated.resources.button_reset_default +import vnidrop.shared.generated.resources.field_username import vnidrop.shared.generated.resources.network_title import vnidrop.shared.generated.resources.node_title import vnidrop.shared.generated.resources.not_initialized @@ -68,6 +83,7 @@ import vnidrop.shared.generated.resources.version_title private enum class SettingsPane { Overview, + Preferences, Appearance, About, } @@ -77,8 +93,9 @@ fun SettingsScreen( deviceInfo: DeviceInfo, coreState: CoreUiState, themeMode: ThemeMode, + preferencesState: PreferencesUiState, windowClass: WindowClass, - onThemeModeChange: (ThemeMode) -> Unit, + onEvent: (VniDropAppEvent) -> Unit, ) { var pane by remember { mutableStateOf(SettingsPane.Overview) } @@ -89,7 +106,8 @@ fun SettingsScreen( deviceInfo = deviceInfo, coreState = coreState, themeMode = themeMode, - onThemeModeChange = onThemeModeChange, + preferencesState = preferencesState, + onEvent = onEvent, ) else -> MobileSettings( pane = pane, @@ -97,7 +115,8 @@ fun SettingsScreen( deviceInfo = deviceInfo, coreState = coreState, themeMode = themeMode, - onThemeModeChange = onThemeModeChange, + preferencesState = preferencesState, + onEvent = onEvent, ) } } @@ -109,7 +128,8 @@ private fun MobileSettings( deviceInfo: DeviceInfo, coreState: CoreUiState, themeMode: ThemeMode, - onThemeModeChange: (ThemeMode) -> Unit, + preferencesState: PreferencesUiState, + onEvent: (VniDropAppEvent) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { ErrorSection(coreState) @@ -117,13 +137,21 @@ private fun MobileSettings( SettingsPane.Overview -> SettingsOverview( coreState = coreState, themeMode = themeMode, + preferencesState = preferencesState, + onOpenPreferences = { onPaneSelected(SettingsPane.Preferences) }, onOpenAppearance = { onPaneSelected(SettingsPane.Appearance) }, onOpenAbout = { onPaneSelected(SettingsPane.About) }, largeTitle = true, ) + SettingsPane.Preferences -> PreferencesSettings( + preferencesState = preferencesState, + onEvent = onEvent, + onBack = { onPaneSelected(SettingsPane.Overview) }, + showBack = true, + ) SettingsPane.Appearance -> AppearanceSettings( themeMode = themeMode, - onThemeModeChange = onThemeModeChange, + onThemeModeChange = { onEvent(VniDropAppEvent.ThemeModeChanged(it)) }, onBack = { onPaneSelected(SettingsPane.Overview) }, showBack = true, ) @@ -144,7 +172,8 @@ private fun DesktopSettings( deviceInfo: DeviceInfo, coreState: CoreUiState, themeMode: ThemeMode, - onThemeModeChange: (ThemeMode) -> Unit, + preferencesState: PreferencesUiState, + onEvent: (VniDropAppEvent) -> Unit, ) { Row( modifier = Modifier.fillMaxWidth(), @@ -158,6 +187,8 @@ private fun DesktopSettings( SettingsOverview( coreState = coreState, themeMode = themeMode, + preferencesState = preferencesState, + onOpenPreferences = { onPaneSelected(SettingsPane.Preferences) }, onOpenAppearance = { onPaneSelected(SettingsPane.Appearance) }, onOpenAbout = { onPaneSelected(SettingsPane.About) }, largeTitle = false, @@ -170,9 +201,15 @@ private fun DesktopSettings( ) { when (selectedPane) { SettingsPane.Overview, + SettingsPane.Preferences -> PreferencesSettings( + preferencesState = preferencesState, + onEvent = onEvent, + onBack = {}, + showBack = false, + ) SettingsPane.Appearance -> AppearanceSettings( themeMode = themeMode, - onThemeModeChange = onThemeModeChange, + onThemeModeChange = { onEvent(VniDropAppEvent.ThemeModeChanged(it)) }, onBack = {}, showBack = false, ) @@ -191,6 +228,8 @@ private fun DesktopSettings( private fun SettingsOverview( coreState: CoreUiState, themeMode: ThemeMode, + preferencesState: PreferencesUiState, + onOpenPreferences: () -> Unit, onOpenAppearance: () -> Unit, onOpenAbout: () -> Unit, largeTitle: Boolean, @@ -203,6 +242,13 @@ private fun SettingsOverview( Text(stringResource(Res.string.settings_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) } SettingsGroup { + SettingsRow( + icon = SettingsIcons.Device, + title = stringResource(Res.string.preferences_title), + value = preferencesState.username, + selected = selectedPane == SettingsPane.Preferences, + onClick = onOpenPreferences, + ) SettingsRow( icon = SettingsIcons.Sun, title = stringResource(Res.string.appearance_title), @@ -228,6 +274,46 @@ private fun SettingsOverview( } } +@Composable +private fun PreferencesSettings( + preferencesState: PreferencesUiState, + onEvent: (VniDropAppEvent) -> Unit, + onBack: () -> Unit, + showBack: Boolean, +) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + SettingsTopBar(title = stringResource(Res.string.preferences_title), onBack = onBack, showBack = showBack) + Field( + value = preferencesState.username, + onValueChange = { onEvent(VniDropAppEvent.UsernameChanged(it)) }, + label = stringResource(Res.string.field_username), + ) + SettingsGroup { + SettingsRow( + icon = SettingsIcons.Folder, + title = stringResource(Res.string.preferences_receive_folder_title), + value = preferencesState.receiveFolder.displayName.ifBlank { preferencesState.receiveFolder.value }, + iconTone = IconTone.Neutral, + ) + SettingsRow( + icon = SettingsIcons.Check, + title = preferencesState.folderAccessStatus.displayName(isValidating = preferencesState.isValidatingFolder), + iconTone = IconTone.Neutral, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + PrimaryButton( + text = stringResource(Res.string.button_choose_folder), + onClick = { onEvent(VniDropAppEvent.ChooseReceiveFolderClicked) }, + ) + SecondaryButton( + text = stringResource(Res.string.button_reset_default), + onClick = { onEvent(VniDropAppEvent.ResetReceiveFolderClicked) }, + ) + } + } +} + @Composable private fun AppearanceSettings( themeMode: ThemeMode, @@ -479,6 +565,18 @@ private fun ThemeMode.displayName(): String = ThemeMode.Dark -> stringResource(Res.string.appearance_dark_mode) } +@Composable +private fun FolderAccessStatus.displayName(isValidating: Boolean): String = + if (isValidating) { + stringResource(Res.string.folder_status_validating) + } else { + when (this) { + FolderAccessStatus.Writable -> stringResource(Res.string.folder_status_writable) + FolderAccessStatus.PermissionRequired -> stringResource(Res.string.folder_status_permission_required) + FolderAccessStatus.Unavailable -> stringResource(Res.string.folder_status_unavailable) + } + } + private enum class IconTone { Brand, Neutral, @@ -533,6 +631,15 @@ private object SettingsIcons { moveTo(11f, 18f) lineTo(13f, 18f) } + val Folder = lineIcon("Folder") { + moveTo(3f, 7f) + lineTo(9f, 7f) + lineTo(11f, 9f) + lineTo(21f, 9f) + lineTo(21f, 19f) + lineTo(3f, 19f) + close() + } val Info = lineIcon("Info") { moveTo(12f, 16f) lineTo(12f, 12f) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt index 6e34ad8..223d21e 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt @@ -2,6 +2,9 @@ package com.vnidrop.app.ui.state import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.navigation.AppDestination +import com.vnidrop.app.core.FolderAccessStatus +import com.vnidrop.app.core.ReceiveFolder +import com.vnidrop.app.core.ReceiveFolderKind import uniffi.vnidrop.CoreEvent import uniffi.vnidrop.StoredTransfer import kotlin.math.roundToInt @@ -27,6 +30,20 @@ data class AppUiState( val themeMode: ThemeMode = ThemeMode.System, ) +data class PreferencesUiState( + val username: String = "", + val receiveFolder: ReceiveFolder = ReceiveFolder( + kind = ReceiveFolderKind.FileSystemPath, + value = "", + displayName = "", + ), + val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable, + val isValidatingFolder: Boolean = false, +) { + val canReceiveIntoFolder: Boolean + get() = folderAccessStatus == FolderAccessStatus.Writable +} + data class SendUiState( val selectedSource: String = "", val selectedDisplayName: String = "", @@ -72,6 +89,9 @@ fun displayNameForStatus(status: String): String = else -> status.replaceFirstChar { it.uppercase() } } +fun StoredTransfer.isActiveTransfer(): Boolean = + status.lowercase() in activeTransferStatuses + fun summarizeProgress(events: List): List = events .filter { event -> event.transferId != null && event.phase in progressPhases } @@ -123,6 +143,7 @@ fun friendlyCoreError(raw: String?): String? { } private val progressPhases = setOf("import", "ticket", "access", "transfer", "download", "export", "lifecycle") +private val activeTransferStatuses = setOf("sharing", "receiving") private fun eventLabel(event: CoreEvent): String { val direction = event.direction?.replaceFirstChar { it.uppercase() } diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt index d991a3a..e88097d 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt @@ -1,11 +1,15 @@ package com.vnidrop.app.ui.state +import com.vnidrop.app.core.FolderAccessStatus +import com.vnidrop.app.core.ReceiveFolder +import com.vnidrop.app.core.ReceiveFolderKind import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.resolveDarkTheme import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +import uniffi.vnidrop.StoredTransfer class AppUiModelsTest { @Test @@ -69,4 +73,40 @@ class AppUiModelsTest { assertFalse(ready.copy(outputDirectory = "").canReceive(isCoreInitialized = true)) assertFalse(ready.copy(isReceiving = true).canReceive(isCoreInitialized = true)) } + + @Test + fun preferencesStateExposesReceiveFolderEligibility() { + val folder = ReceiveFolder( + kind = ReceiveFolderKind.FileSystemPath, + value = "/tmp/downloads", + displayName = "Downloads", + ) + + assertTrue(PreferencesUiState(receiveFolder = folder, folderAccessStatus = FolderAccessStatus.Writable).canReceiveIntoFolder) + assertFalse(PreferencesUiState(receiveFolder = folder, folderAccessStatus = FolderAccessStatus.PermissionRequired).canReceiveIntoFolder) + assertFalse(PreferencesUiState(receiveFolder = folder, folderAccessStatus = FolderAccessStatus.Unavailable).canReceiveIntoFolder) + } + + @Test + fun transferActivityOnlyIncludesRunningStatuses() { + assertTrue(storedTransfer(status = "sharing").isActiveTransfer()) + assertTrue(storedTransfer(status = "receiving").isActiveTransfer()) + assertFalse(storedTransfer(status = "done").isActiveTransfer()) + assertFalse(storedTransfer(status = "failed").isActiveTransfer()) + assertFalse(storedTransfer(status = "cancelled").isActiveTransfer()) + } + + private fun storedTransfer(status: String): StoredTransfer = + StoredTransfer( + transferId = 1UL, + direction = "send", + status = status, + transferName = "Demo", + contentHash = null, + ticket = null, + fileCount = 1UL, + totalSize = 128UL, + createdAt = 1L, + updatedAt = 1L, + ) } diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt index 134dac9..d710b7e 100644 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt @@ -8,6 +8,7 @@ import platform.UIKit.UIApplication import platform.UIKit.UIDocumentPickerDelegateProtocol import platform.UIKit.UIDocumentPickerViewController import platform.UIKit.UIModalPresentationFormSheet +import platform.UniformTypeIdentifiers.UTTypeFolder import platform.UniformTypeIdentifiers.UTTypeItem import platform.darwin.NSObject @@ -37,6 +38,41 @@ actual fun rememberShareFilePicker( } } +@Composable +actual fun rememberReceiveFolderPicker( + onFolderPicked: (ReceiveFolder) -> Unit, + onError: (String) -> Unit, +): ReceiveFolderPicker = remember(onFolderPicked, onError) { + object : ReceiveFolderPicker { + @OptIn(ExperimentalForeignApi::class) + override fun pickFolder() { + val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController + if (presenter == null) { + onError("Could not find an iOS view controller for the folder picker") + return + } + + val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder), asCopy = false) + val delegate = DocumentPickerDelegate( + onFilePicked = { folder -> + onFolderPicked( + ReceiveFolder( + kind = ReceiveFolderKind.IosSecurityScopedUrl, + value = folder.value, + displayName = folder.displayName, + ), + ) + }, + onError = onError, + ) + retainedPickerDelegate = delegate + picker.delegate = delegate + picker.modalPresentationStyle = UIModalPresentationFormSheet + presenter.presentViewController(picker, animated = true, completion = null) + } + } +} + actual suspend fun sharePickedFile( repository: CoreRepository, file: PickedShareFile, diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt new file mode 100644 index 0000000..bd9b711 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt @@ -0,0 +1,59 @@ +package com.vnidrop.app.core + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import platform.Foundation.NSFileManager +import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSSearchPathForDirectoriesInDomains +import platform.Foundation.NSURL +import platform.Foundation.NSUserDomainMask +import uniffi.vnidrop.ReceiveOutputSink + +@Composable +actual fun rememberFileSystemService(): FileSystemService = + remember { IosFileSystemService() } + +private class IosFileSystemService : FileSystemService { + override fun defaultReceiveFolder(): ReceiveFolder { + val path = NSSearchPathForDirectoriesInDomains( + NSDocumentDirectory, + NSUserDomainMask, + true, + ).firstOrNull() as? String ?: "" + return ReceiveFolder( + kind = ReceiveFolderKind.FileSystemPath, + value = path, + displayName = "Documents", + ) + } + + override suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus = + when (folder.kind) { + ReceiveFolderKind.FileSystemPath -> { + if (NSFileManager.defaultManager.isWritableFileAtPath(folder.value)) { + FolderAccessStatus.Writable + } else { + FolderAccessStatus.Unavailable + } + } + ReceiveFolderKind.IosSecurityScopedUrl -> validateSecurityScopedUrl(folder.value) + ReceiveFolderKind.AndroidTreeUri -> FolderAccessStatus.Unavailable + } + + override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null + + private fun validateSecurityScopedUrl(value: String): FolderAccessStatus { + val url = NSURL.URLWithString(value) ?: NSURL.fileURLWithPath(value) + val didStartAccess = url.startAccessingSecurityScopedResource() + return try { + val path = url.path + if (path != null && NSFileManager.defaultManager.isWritableFileAtPath(path)) { + FolderAccessStatus.Writable + } else { + FolderAccessStatus.PermissionRequired + } + } finally { + if (didStartAccess) url.stopAccessingSecurityScopedResource() + } + } +} diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt index 03a9bb9..b0298b3 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt @@ -2,9 +2,12 @@ package com.vnidrop.app.core import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import java.awt.EventQueue import java.awt.FileDialog import java.awt.Frame +import java.awt.KeyboardFocusManager import java.io.File +import javax.swing.JFileChooser @Composable actual fun rememberShareFilePicker( @@ -13,17 +16,29 @@ actual fun rememberShareFilePicker( ): ShareFilePicker = remember(onFilePicked, onError) { object : ShareFilePicker { override fun pickFile() { - try { - val dialog = FileDialog(null as Frame?, "Select file to share", FileDialog.LOAD) - dialog.isVisible = true - val directory = dialog.directory - val file = dialog.file - if (directory != null && file != null) { - val selected = File(directory, file) - onFilePicked(PickedShareFile(selected.absolutePath, selected.name)) - } - } catch (error: Throwable) { - onError(error.message ?: error.toString()) + openPicker(onError) { + pickShareFile()?.let(onFilePicked) + } + } + } +} + +@Composable +actual fun rememberReceiveFolderPicker( + onFolderPicked: (ReceiveFolder) -> Unit, + onError: (String) -> Unit, +): ReceiveFolderPicker = remember(onFolderPicked, onError) { + object : ReceiveFolderPicker { + override fun pickFolder() { + openPicker(onError) { + val selected = pickDirectory() ?: return@openPicker + onFolderPicked( + ReceiveFolder( + kind = ReceiveFolderKind.FileSystemPath, + value = selected.absolutePath, + displayName = selected.name.ifBlank { selected.absolutePath }, + ), + ) } } } @@ -37,3 +52,85 @@ actual suspend fun sharePickedFile( ) { repository.sharePath(file.value, transferName, senderName) } + +private fun openPicker( + onError: (String) -> Unit, + block: () -> Unit, +) { + EventQueue.invokeLater { + try { + block() + } catch (error: Throwable) { + onError(error.message ?: error.toString()) + } + } +} + +private fun nativeFileDialog(title: String): FileDialog = + FileDialog(activeFrame(), title, FileDialog.LOAD) + +private fun activeFrame(): Frame? { + val activeWindow = KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow + return activeWindow as? Frame + ?: Frame.getFrames().firstOrNull { it.isActive || it.isFocused } + ?: Frame.getFrames().firstOrNull { it.isVisible } +} + +private fun pickShareFile(): PickedShareFile? { + val dialog = nativeFileDialog("Select file to share") + return try { + dialog.isVisible = true + val directory = dialog.directory + val file = dialog.file + if (directory != null && file != null) { + val selected = File(directory, file) + PickedShareFile(selected.absolutePath, selected.name) + } else { + null + } + } finally { + dialog.dispose() + } +} + +private fun pickDirectory(): File? = + if (isMacOs()) { + val dialog = withMacDirectoryDialog { + nativeFileDialog("Select receive folder").apply { isVisible = true } + } + try { + val directory = dialog.directory ?: return null + dialog.file + ?.let { File(directory, it) } + ?: File(directory) + } finally { + dialog.dispose() + } + } else { + val chooser = JFileChooser().apply { + dialogTitle = "Select receive folder" + fileSelectionMode = JFileChooser.DIRECTORIES_ONLY + isAcceptAllFileFilterUsed = false + } + if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) chooser.selectedFile else null + } + +private fun withMacDirectoryDialog(block: () -> T): T { + if (!isMacOs()) return block() + + val key = "apple.awt.fileDialogForDirectories" + val previous = System.getProperty(key) + System.setProperty(key, "true") + return try { + block() + } finally { + if (previous == null) { + System.clearProperty(key) + } else { + System.setProperty(key, previous) + } + } +} + +private fun isMacOs(): Boolean = + System.getProperty("os.name").startsWith("Mac", ignoreCase = true) diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt new file mode 100644 index 0000000..01ebcd2 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt @@ -0,0 +1,30 @@ +package com.vnidrop.app.core + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import uniffi.vnidrop.ReceiveOutputSink +import java.io.File + +@Composable +actual fun rememberFileSystemService(): FileSystemService = + remember { JvmFileSystemService() } + +private class JvmFileSystemService : FileSystemService { + override fun defaultReceiveFolder(): ReceiveFolder = + ReceiveFolder( + kind = ReceiveFolderKind.FileSystemPath, + value = File(System.getProperty("user.home"), "Downloads").absolutePath, + displayName = "Downloads", + ) + + override suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus { + if (folder.kind != ReceiveFolderKind.FileSystemPath) return FolderAccessStatus.Unavailable + return runCatching { + val directory = File(folder.value) + if (!directory.exists()) directory.mkdirs() + if (directory.isDirectory && directory.canWrite()) FolderAccessStatus.Writable else FolderAccessStatus.Unavailable + }.getOrDefault(FolderAccessStatus.Unavailable) + } + + override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null +} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt new file mode 100644 index 0000000..255885f --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt @@ -0,0 +1,52 @@ +package com.vnidrop.app.preferences + +import com.vnidrop.app.core.ReceiveFolder +import com.vnidrop.app.core.ReceiveFolderKind +import com.vnidrop.app.ui.theme.ThemeMode +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +class AppPreferencesRepositoryTest { + @Test + fun preferencesUseDefaultsWhenNothingIsStored() = runBlocking { + val repository = repositoryForTest() + + val preferences = repository.preferences.first() + + assertEquals("Device Name", preferences.username) + assertEquals(defaultFolder, preferences.receiveFolder) + assertEquals(ThemeMode.System, preferences.themeMode) + } + + @Test + fun themeModeIsPersisted() = runBlocking { + val repository = repositoryForTest() + + repository.setThemeMode(ThemeMode.Dark) + + assertEquals(ThemeMode.Dark, repository.preferences.first().themeMode) + } + + private fun repositoryForTest(): AppPreferencesRepository { + val directory = Files.createTempDirectory("vnidrop-preferences-test").toString() + return AppPreferencesRepository( + dataStore = createAppPreferencesDataStore(directory), + defaults = AppPreferencesDefaults( + username = "Device Name", + receiveFolder = defaultFolder, + themeMode = ThemeMode.System, + ), + ) + } + + private companion object { + val defaultFolder = ReceiveFolder( + kind = ReceiveFolderKind.FileSystemPath, + value = "/tmp/Downloads", + displayName = "Downloads", + ) + } +}