diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 2f9818c..f7ba8e4 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -5,6 +5,10 @@ + + + + + + + + + + + + + + + + + + + + + + + + externalInvitations.reportOpenFailure(error.message ?: "The invitation could not be opened") + } } } + + private fun readInvitation(uri: Uri, declaredType: String?): Result = runCatching { + val resolvedType = declaredType ?: contentResolver.getType(uri) + val path = uri.path.orEmpty() + val lastSegment = uri.lastPathSegment.orEmpty() + val hasExpectedName = lastSegment.endsWith(".$VniDropInvitationExtension", ignoreCase = true) || + path.endsWith(".$VniDropInvitationExtension", ignoreCase = true) + require(resolvedType == VniDropInvitationMimeType || hasExpectedName) { "This is not a VniDrop invitation" } + val bytes = contentResolver.openInputStream(uri)?.use { it.readNBytes(MaxVniDropInvitationBytes + 1) } + ?: error("The invitation could not be opened") + decodeInvitationBytes(bytes) + } } diff --git a/crates/vnidrop/CORE_FLOW.md b/crates/vnidrop/CORE_FLOW.md index 96cf5bd..6184f33 100644 --- a/crates/vnidrop/CORE_FLOW.md +++ b/crates/vnidrop/CORE_FLOW.md @@ -59,9 +59,18 @@ bytes through Kotlin memory. - Persisted shares are restored only when their root collection is complete and readable. Missing or corrupt roots fail closed and emit a recovery event. - Receive destinations use a no-overwrite policy. Rust writes a uniquely named - temporary file in the destination directory, syncs it, and atomically - publishes it with a no-clobber hard link. Failure or cancellation removes the - temporary file. Stale VniDrop temporary files are cleaned on later writes. + temporary file in the destination directory, syncs it, and publishes it with + a no-clobber hard link when the filesystem supports it. On platforms that + reject hard links (notably Android emulated external storage), publication + falls back to an exclusive rename (`renameat2(RENAME_NOREPLACE)` / + `renamex_np(RENAME_EXCL)`). Failure or cancellation removes the temporary + file. Stale VniDrop temporary files are cleaned on later writes. +- Android defaults to the shared system Downloads collection via MediaStore + (`ReceiveFolderKind.AndroidPublicDownloads` on API 29+). Files show up in the + user's Downloads UI like a browser download. Custom folders still use a SAF + tree URI from the folder picker. Both Android sinks stream through + `ReceiveOutputSink` instead of raw filesystem paths. Pre-Android 10 falls + back to the public Downloads path with legacy storage permission. - Foreign output sinks receive exactly one terminal callback after a successful `start_file`: `finish_file` or `abort_file`. diff --git a/crates/vnidrop/src/filesystem.rs b/crates/vnidrop/src/filesystem.rs index 44d0056..0d3c9a6 100644 --- a/crates/vnidrop/src/filesystem.rs +++ b/crates/vnidrop/src/filesystem.rs @@ -101,18 +101,134 @@ impl AtomicOutputFile { } pub(crate) fn commit(mut self) -> Result<()> { - // Creating a hard link is an atomic no-clobber publication on the same - // filesystem. It fails if another writer created the destination. - std::fs::hard_link(&self.temporary, &self.target) + publish_temp_as_final(&self.temporary, &self.target) .with_context(|| format!("failed to commit {}", self.target.display()))?; self.committed = true; - if let Err(error) = std::fs::remove_file(&self.temporary) { - tracing::warn!(%error, path = %self.temporary.display(), "failed to remove committed temporary file"); - } Ok(()) } } +/// Publish a fully written temporary file as the final destination without +/// clobbering an existing peer. +/// +/// Prefer a same-directory hard link (atomic no-clobber on most Unix volumes). +/// Android's emulated external storage and some FUSE mounts reject hard links +/// even when ordinary create/write/rename work, so fall back to an exclusive +/// rename when the link is unsupported. +fn publish_temp_as_final(temporary: &Path, target: &Path) -> io::Result<()> { + match std::fs::hard_link(temporary, target) { + Ok(()) => { + if let Err(error) = std::fs::remove_file(temporary) { + tracing::warn!( + %error, + path = %temporary.display(), + "failed to remove committed temporary file" + ); + } + Ok(()) + } + Err(error) if is_hard_link_unsupported(&error) => rename_no_replace(temporary, target), + Err(error) => Err(error), + } +} + +fn is_hard_link_unsupported(error: &io::Error) -> bool { + match error.raw_os_error() { + Some(code) + if code == libc::EPERM + || code == libc::EACCES + || code == libc::EOPNOTSUPP + || code == libc::ENOTSUP + || code == libc::EXDEV + || code == libc::EINVAL + || code == libc::ENOSYS => + { + true + } + _ => matches!( + error.kind(), + io::ErrorKind::Unsupported | io::ErrorKind::PermissionDenied + ), + } +} + +fn rename_no_replace(from: &Path, to: &Path) -> io::Result<()> { + #[cfg(any(target_os = "linux", target_os = "android"))] + { + match renameat2_noreplace(from, to) { + Ok(()) => return Ok(()), + Err(error) + if error.raw_os_error() == Some(libc::ENOSYS) + || error.raw_os_error() == Some(libc::EINVAL) => {} + Err(error) => return Err(error), + } + } + + #[cfg(target_os = "macos")] + { + match renamex_np_excl(from, to) { + Ok(()) => return Ok(()), + Err(error) if error.raw_os_error() == Some(libc::ENOTSUP) => {} + Err(error) => return Err(error), + } + } + + // Last resort: refuse an existing destination, then rename. There is a + // small race versus concurrent writers, but this path only runs when the + // platform lacks both hard links and exclusive rename. + if std::fs::symlink_metadata(to).is_ok() { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("destination already exists: {}", to.display()), + )); + } + std::fs::rename(from, to) +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +fn renameat2_noreplace(from: &Path, to: &Path) -> io::Result<()> { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let from_c = CString::new(from.as_os_str().as_bytes()) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let to_c = CString::new(to.as_os_str().as_bytes()) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + // Android defines RENAME_NOREPLACE as c_int while renameat2 takes c_uint. + let flags = libc::RENAME_NOREPLACE as libc::c_uint; + let rc = unsafe { + libc::renameat2( + libc::AT_FDCWD, + from_c.as_ptr(), + libc::AT_FDCWD, + to_c.as_ptr(), + flags, + ) + }; + if rc == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +#[cfg(target_os = "macos")] +fn renamex_np_excl(from: &Path, to: &Path) -> io::Result<()> { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let from_c = CString::new(from.as_os_str().as_bytes()) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let to_c = CString::new(to.as_os_str().as_bytes()) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let rc = unsafe { libc::renamex_np(from_c.as_ptr(), to_c.as_ptr(), libc::RENAME_EXCL) }; + if rc == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + pub(crate) fn cleanup_stale_temporary_files( directory: &Path, minimum_age: Duration, diff --git a/crates/vnidrop/src/repository.rs b/crates/vnidrop/src/repository.rs index fe347be..fedd924 100644 --- a/crates/vnidrop/src/repository.rs +++ b/crates/vnidrop/src/repository.rs @@ -27,6 +27,8 @@ pub(crate) struct Repository { pool: SqlitePool, #[cfg(test)] fail_next_write: Arc, + #[cfg(test)] + fail_receive_history_after_dependants: Arc, } pub(crate) struct TransferUpsert<'a> { @@ -80,6 +82,8 @@ impl Repository { pool, #[cfg(test)] fail_next_write: Arc::new(AtomicBool::new(false)), + #[cfg(test)] + fail_receive_history_after_dependants: Arc::new(AtomicBool::new(false)), }; repository.ensure_schema().await?; Ok(repository) @@ -482,6 +486,12 @@ impl Repository { self.fail_next_write.store(true, Ordering::SeqCst); } + #[cfg(test)] + pub(crate) fn fail_receive_history_after_dependants(&self) { + self.fail_receive_history_after_dependants + .store(true, Ordering::SeqCst); + } + #[cfg(test)] fn maybe_fail_write(&self) -> Result<()> { if self.fail_next_write.swap(false, Ordering::SeqCst) { @@ -759,6 +769,60 @@ impl Repository { Ok(()) } + pub(crate) async fn delete_receive_history(&self) -> Result { + self.maybe_fail_write()?; + let mut transaction = self.pool.begin().await?; + + // Delete dependants before their transfer rows. Keep the terminal-state + // predicate on every statement so receive work that is still active and + // every send record remain outside this transaction's scope. + sqlx::query( + r#" + DELETE FROM receiver_requests + WHERE transfer_id IN ( + SELECT transfer_id + FROM transfers + WHERE direction = 'receive' + AND status IN ('done', 'failed', 'cancelled') + ) + "#, + ) + .execute(&mut *transaction) + .await?; + sqlx::query( + r#" + DELETE FROM transfer_events + WHERE transfer_id IN ( + SELECT transfer_id + FROM transfers + WHERE direction = 'receive' + AND status IN ('done', 'failed', 'cancelled') + ) + "#, + ) + .execute(&mut *transaction) + .await?; + #[cfg(test)] + if self + .fail_receive_history_after_dependants + .swap(false, Ordering::SeqCst) + { + anyhow::bail!("injected receive history failure after dependant deletion"); + } + let deleted = sqlx::query( + r#" + DELETE FROM transfers + WHERE direction = 'receive' + AND status IN ('done', 'failed', 'cancelled') + "#, + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + Ok(deleted.rows_affected()) + } + pub(crate) async fn list_events( &self, transfer_id: Option, diff --git a/crates/vnidrop/src/runtime.rs b/crates/vnidrop/src/runtime.rs index 311126f..9a8b75b 100644 --- a/crates/vnidrop/src/runtime.rs +++ b/crates/vnidrop/src/runtime.rs @@ -250,6 +250,12 @@ impl VnidropCore { .map_err(VnidropError::transfer) } + pub fn delete_receive_history(&self) -> Result { + self.runtime + .block_on(self.inner.delete_receive_history()) + .map_err(VnidropError::repository) + } + pub fn set_transfer_access_mode( &self, transfer_id: u64, @@ -992,9 +998,20 @@ impl CoreInner { .await .retain(|_, id| *id != transfer_id); self.access_policy.remove_transfer(transfer_id).await; + // Events are persisted asynchronously. Drain events emitted before this + // request so none can be written back after the transfer is deleted. + self.event_hub.flush().await; self.repository.delete_transfer(transfer_id).await } + async fn delete_receive_history(&self) -> Result { + // Transfer events are persisted on a background task. Drain everything + // emitted before this request so cleared history cannot be reinserted + // after the repository transaction commits. + self.event_hub.flush().await; + self.repository.delete_receive_history().await + } + async fn set_transfer_access_mode( &self, transfer_id: u64, diff --git a/crates/vnidrop/src/tests/filesystem.rs b/crates/vnidrop/src/tests/filesystem.rs index 38c9937..93f4b5c 100644 --- a/crates/vnidrop/src/tests/filesystem.rs +++ b/crates/vnidrop/src/tests/filesystem.rs @@ -117,6 +117,25 @@ fn atomic_output_commits_without_overwriting() { ); } +#[test] +fn atomic_output_commit_does_not_clobber_existing_peer() { + let output = tempfile::tempdir().unwrap(); + let target = output.path().join("peer.txt"); + std::fs::write(&target, b"original").unwrap(); + + let (pending, mut file) = AtomicOutputFile::create(output.path(), "other.txt").unwrap(); + std::io::Write::write_all(&mut file, b"new").unwrap(); + drop(file); + pending.commit().unwrap(); + + // Existing peer must stay intact while a different file commits. + assert_eq!(std::fs::read(&target).unwrap(), b"original"); + assert_eq!( + std::fs::read(output.path().join("other.txt")).unwrap(), + b"new" + ); +} + #[test] fn dropped_atomic_output_removes_partial_file() { let output = tempfile::tempdir().unwrap(); diff --git a/crates/vnidrop/src/tests/repository.rs b/crates/vnidrop/src/tests/repository.rs index 1272591..63adcbf 100644 --- a/crates/vnidrop/src/tests/repository.rs +++ b/crates/vnidrop/src/tests/repository.rs @@ -585,6 +585,149 @@ async fn deleting_transfer_removes_related_history_transactionally() { .is_empty()); assert!(repository.delete_transfer(88).await.is_err()); } + +#[tokio::test] +async fn deleting_receive_history_only_removes_terminal_receives_and_dependants() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + let records = [ + (100, TransferDirection::Receive, TransferStatus::Done), + (101, TransferDirection::Receive, TransferStatus::Failed), + (102, TransferDirection::Receive, TransferStatus::Cancelled), + (103, TransferDirection::Receive, TransferStatus::Receiving), + (104, TransferDirection::Send, TransferStatus::Done), + (105, TransferDirection::Send, TransferStatus::Sharing), + ]; + + for (transfer_id, direction, status) in records { + repository + .insert_transfer(transfer(transfer_id, direction, status)) + .await + .unwrap(); + let request_id = format!("request-{transfer_id}"); + repository + .insert_receiver_request(ReceiverRequestInsert { + id: &request_id, + transfer_id, + remote_endpoint_id: "receiver", + transfer_name: "demo", + receiver_name: None, + receiver_device_name: None, + app_version: "1.0", + }) + .await + .unwrap(); + repository + .insert_event( + &CoreEvent { + id: format!("event-{transfer_id}"), + timestamp: transfer_id as i64, + scope: "transfer".to_string(), + transfer_id: Some(transfer_id), + direction: Some(direction.as_str().to_string()), + phase: "test".to_string(), + kind: "created".to_string(), + data_json: "{}".to_string(), + }, + 500, + ) + .await + .unwrap(); + } + + assert_eq!(repository.delete_receive_history().await.unwrap(), 3); + + let remaining = repository.list_transfers().await.unwrap(); + assert_eq!(remaining.len(), 3); + for transfer_id in [103, 104, 105] { + assert!(remaining + .iter() + .any(|transfer| transfer.transfer_id == transfer_id)); + assert_eq!( + repository + .list_receiver_requests(transfer_id) + .await + .unwrap() + .len(), + 1 + ); + assert_eq!( + repository + .list_events(Some(transfer_id), 500) + .await + .unwrap() + .len(), + 1 + ); + } + for transfer_id in [100, 101, 102] { + assert!(repository + .list_receiver_requests(transfer_id) + .await + .unwrap() + .is_empty()); + assert!(repository + .list_events(Some(transfer_id), 500) + .await + .unwrap() + .is_empty()); + } + assert_eq!(repository.delete_receive_history().await.unwrap(), 0); +} + +#[tokio::test] +async fn receive_history_mid_transaction_failure_preserves_all_related_rows() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_transfer(transfer( + 106, + TransferDirection::Receive, + TransferStatus::Done, + )) + .await + .unwrap(); + repository + .insert_receiver_request(ReceiverRequestInsert { + id: "request-preserved", + transfer_id: 106, + remote_endpoint_id: "receiver", + transfer_name: "demo", + receiver_name: None, + receiver_device_name: None, + app_version: "1.0", + }) + .await + .unwrap(); + repository + .insert_event( + &CoreEvent { + id: "event-preserved".to_string(), + timestamp: 1, + scope: "transfer".to_string(), + transfer_id: Some(106), + direction: Some("receive".to_string()), + phase: "test".to_string(), + kind: "created".to_string(), + data_json: "{}".to_string(), + }, + 500, + ) + .await + .unwrap(); + repository.fail_receive_history_after_dependants(); + + assert!(repository.delete_receive_history().await.is_err()); + assert_eq!(repository.list_transfers().await.unwrap().len(), 1); + assert_eq!( + repository.list_receiver_requests(106).await.unwrap().len(), + 1 + ); + assert_eq!( + repository.list_events(Some(106), 500).await.unwrap().len(), + 1 + ); +} use std::str::FromStr; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 803b6e8..a0a7f62 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -25,6 +25,11 @@ compose.desktop { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "com.vnidrop.app" packageVersion = "1.0.0" + fileAssociation( + mimeType = "application/vnd.vnidrop.transfer", + extension = "vnd", + description = "VniDrop Invitation", + ) } } } diff --git a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt index 44ca458..0921de6 100644 --- a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt +++ b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt @@ -4,9 +4,21 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import com.vnidrop.app.platform.DesktopAppearanceBridge import com.vnidrop.app.feature.send.DesktopShareBridge +import com.vnidrop.app.feature.receive.ExternalInvitationController +import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes +import com.vnidrop.app.feature.receive.VniDropInvitationExtension +import com.vnidrop.app.feature.receive.decodeInvitationBytes +import java.awt.Desktop +import java.io.File -fun main() { +fun main(args: Array) { + val externalInvitations = ExternalInvitationController() configureMacOsNativeAppearance() + configureInvitationOpenHandler(externalInvitations) + args.asSequence() + .map(::File) + .filter { it.extension.equals(VniDropInvitationExtension, ignoreCase = true) } + .forEach { externalInvitations.openFile(it) } DesktopAppearanceBridge.applyNativeAppearance = MacOsAppKitAppearance::apply if (System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) { DesktopShareBridge.shareFile = MacOsShareSheet::share @@ -16,11 +28,29 @@ fun main() { onCloseRequest = ::exitApplication, title = "vnidrop", ) { - App(rememberJvmAppDependencies()) + App(rememberJvmAppDependencies(externalInvitations)) } } } +private fun configureInvitationOpenHandler(controller: ExternalInvitationController) { + if (!Desktop.isDesktopSupported()) return + val desktop = Desktop.getDesktop() + if (!desktop.isSupported(Desktop.Action.APP_OPEN_FILE)) return + desktop.setOpenFileHandler { event -> event.files.forEach(controller::openFile) } +} + +private fun ExternalInvitationController.openFile(file: File) { + val result = runCatching { + require(file.extension.equals(VniDropInvitationExtension, ignoreCase = true)) { "This is not a VniDrop invitation" } + val bytes = file.inputStream().use { it.readNBytes(MaxVniDropInvitationBytes + 1) } + decodeInvitationBytes(bytes) + } + result.fold(::openInvitation) { error -> + reportOpenFailure(error.message ?: "The invitation could not be opened") + } +} + private fun configureMacOsNativeAppearance() { if (!System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) return // AWT reads this before creating the first native window. Runtime theme diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2039cee..55363fc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ kotlinx-coroutines = "1.11.0" material3 = "1.11.0-alpha07" qrcode = "4.5.0" jna = "5.17.0" +google-code-scanner = "16.1.0" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -45,6 +46,7 @@ kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-te kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } +google-code-scanner = { module = "com.google.android.gms:play-services-code-scanner", version.ref = "google-code-scanner" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 646ca93..30fd270 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -58,16 +58,68 @@ final class VniDropHostViewController: UIViewController { } struct ComposeView: UIViewControllerRepresentable { + let externalInvitations: ExternalInvitationController + func makeUIViewController(context: Self.Context) -> UIViewController { - VniDropHostViewController(composeController: MainViewControllerKt.MainViewController()) + VniDropHostViewController( + composeController: MainViewControllerKt.MainViewController( + externalInvitations: externalInvitations + ) + ) } func updateUIViewController(_ uiViewController: UIViewController, context: Self.Context) {} } struct ContentView: View { + let externalInvitations: ExternalInvitationController + var body: some View { - ComposeView() + ComposeView(externalInvitations: externalInvitations) .ignoresSafeArea() + .onOpenURL(perform: openInvitation) + } + + private func openInvitation(_ url: URL) { + guard url.pathExtension.caseInsensitiveCompare("vnd") == .orderedSame else { + externalInvitations.reportOpenFailure(message: "This is not a VniDrop invitation") + return + } + + let hasSecurityAccess = url.startAccessingSecurityScopedResource() + defer { + if hasSecurityAccess { + url.stopAccessingSecurityScopedResource() + } + } + + do { + let values = try url.resourceValues(forKeys: [.fileSizeKey]) + if let fileSize = values.fileSize, fileSize > 65_536 { + throw InvitationOpenError.tooLarge + } + let data = try Data(contentsOf: url, options: .mappedIfSafe) + guard data.count <= 65_536 else { throw InvitationOpenError.tooLarge } + guard let raw = String(data: data, encoding: .utf8) else { + throw InvitationOpenError.invalidEncoding + } + externalInvitations.openInvitation(raw: raw) + } catch { + externalInvitations.reportOpenFailure( + message: (error as? LocalizedError)?.errorDescription ?? "The invitation could not be opened" + ) + } + } +} + +private enum InvitationOpenError: LocalizedError { + case tooLarge + case invalidEncoding + + var errorDescription: String? { + switch self { + case .tooLarge: "The invitation is too large" + case .invalidEncoding: "The invitation is not valid text" + } } } diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index df2d283..1bc96a2 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -4,6 +4,45 @@ CADisableMinimumFrameDurationOnPhone + CFBundleDocumentTypes + + + CFBundleTypeName + VniDrop Invitation + CFBundleTypeRole + Viewer + LSHandlerRank + Owner + LSItemContentTypes + + com.vnidrop.app.invitation + + + + UTExportedTypeDeclarations + + + UTTypeConformsTo + + public.data + + UTTypeDescription + VniDrop Invitation + UTTypeIdentifier + com.vnidrop.app.invitation + UTTypeTagSpecification + + public.filename-extension + + vnd + + public.mime-type + application/vnd.vnidrop.transfer + + + + LSSupportsOpeningDocumentsInPlace + UIViewControllerBasedStatusBarAppearance diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 927e0b9..08210e4 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -1,10 +1,13 @@ +import Shared import SwiftUI @main struct iOSApp: App { + private let externalInvitations = ExternalInvitationController() + var body: some Scene { WindowGroup { - ContentView() + ContentView(externalInvitations: externalInvitations) } } } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index ddb9bf6..2220fd4 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,6 +1,8 @@ @file:OptIn(gobley.gradle.InternalGobleyGradleApi::class) import gobley.gradle.cargo.dsl.appleMobile +import gobley.gradle.cargo.dsl.jvm +import gobley.gradle.GobleyHost import gobley.gradle.rust.targets.RustAndroidTarget import org.gradle.api.tasks.PathSensitivity import org.jetbrains.kotlin.gradle.dsl.JvmTarget @@ -38,6 +40,7 @@ kotlin { androidMain.dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.core.ktx) + implementation(libs.google.code.scanner) implementation(libs.compose.uiToolingPreview) } commonMain.dependencies { @@ -87,6 +90,13 @@ cargo { packageDirectory = layout.projectDirectory.dir("../crates/vnidrop") publishJvmArtifacts = true androidTargetsToBuild.set(setOf(RustAndroidTarget.Arm64)) + builds.jvm { + variants { + // Desktop distributions are built per host. Do not publish disabled + // cross-platform native jars into the app runtime classpath. + embedRustLibrary.set(rustTarget == GobleyHost.current.rustTarget) + } + } builds.appleMobile { variants { buildTaskProvider.configure { diff --git a/shared/src/androidMain/AndroidManifest.xml b/shared/src/androidMain/AndroidManifest.xml index b5b2645..ef2a932 100644 --- a/shared/src/androidMain/AndroidManifest.xml +++ b/shared/src/androidMain/AndroidManifest.xml @@ -2,4 +2,7 @@ + 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 b96e861..b794cfd 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt @@ -10,10 +10,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.vnidrop.app.core.rememberFileSystemService import com.vnidrop.app.notifications.rememberAndroidLocalNotificationService +import com.vnidrop.app.feature.receive.ExternalInvitationController import java.net.NetworkInterface @Composable -fun rememberAndroidAppDependencies(activity: ComponentActivity): AppDependencies { +fun rememberAndroidAppDependencies(activity: ComponentActivity, externalInvitations: ExternalInvitationController): AppDependencies { val context = activity.applicationContext val fileSystemService = rememberFileSystemService() val notificationService = rememberAndroidLocalNotificationService(activity) @@ -28,6 +29,7 @@ fun rememberAndroidAppDependencies(activity: ComponentActivity): AppDependencies deviceInfoProvider = AndroidDeviceInfoProvider(context), fileSystemService = fileSystemService, localNotificationService = notificationService, + externalInvitations = externalInvitations, ) } } diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt index 5f3d8ee..49cea2c 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt @@ -1,15 +1,21 @@ package com.vnidrop.app.core +import android.content.ContentValues import android.content.Context import android.net.Uri +import android.os.Build import android.os.Environment import android.provider.DocumentsContract -import androidx.core.net.toUri +import android.provider.MediaStore +import android.webkit.MimeTypeMap import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext +import androidx.core.net.toUri import uniffi.vnidrop.ReceiveOutputSink +import java.io.File import java.io.OutputStream +import java.net.URLConnection import java.util.UUID @Composable @@ -22,28 +28,40 @@ 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", - ) + // Match desktop: shared system Downloads. On Android 10+ this is MediaStore, + // not a raw filesystem path (scoped storage). Older APIs fall back to the + // public Downloads directory when legacy storage writes are allowed. + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ReceiveFolder( + kind = ReceiveFolderKind.AndroidPublicDownloads, + value = AndroidPublicDownloadsToken, + displayName = "Downloads", + ) + } else { + val publicDownloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + ReceiveFolder( + kind = ReceiveFolderKind.FileSystemPath, + value = publicDownloads.absolutePath, + displayName = "Downloads", + ) + } } override suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus = when (folder.kind) { ReceiveFolderKind.FileSystemPath -> validatePath(folder.value) + ReceiveFolderKind.AndroidPublicDownloads -> validatePublicDownloads() ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value) ReceiveFolderKind.IosSecurityScopedUrl -> FolderAccessStatus.Unavailable } - override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? { - if (folder.kind != ReceiveFolderKind.AndroidTreeUri) return null - return AndroidTreeReceiveOutputSink(context, folder.value.toUri()) - } + override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = + when (folder.kind) { + ReceiveFolderKind.AndroidPublicDownloads -> AndroidMediaStoreDownloadsSink(context) + ReceiveFolderKind.AndroidTreeUri -> AndroidTreeReceiveOutputSink(context, folder.value.toUri()) + ReceiveFolderKind.FileSystemPath, + ReceiveFolderKind.IosSecurityScopedUrl -> null + } override suspend fun sharePickedFile( repository: CoreGateway, @@ -64,11 +82,37 @@ private class AndroidFileSystemService( } } + /** + * Probe a real create/write/delete instead of [File.canWrite]. + * + * Scoped storage often reports public directories as writable even when + * the process cannot create files there. A probe matches what receive needs. + */ 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 + val directory = File(path) + if (!directory.exists() && !directory.mkdirs()) { + return FolderAccessStatus.Unavailable + } + if (!directory.isDirectory) return FolderAccessStatus.Unavailable + val probe = File(directory, ".vnidrop-write-test-${UUID.randomUUID()}") + try { + probe.outputStream().use { stream -> stream.write(1) } + if (!probe.exists()) return FolderAccessStatus.Unavailable + FolderAccessStatus.Writable + } finally { + probe.delete() + } + }.getOrDefault(FolderAccessStatus.Unavailable) + + private fun validatePublicDownloads(): FolderAccessStatus = + runCatching { + val probeName = ".vnidrop-write-test-${UUID.randomUUID()}" + val sink = AndroidMediaStoreDownloadsSink(context) + sink.startFile(probeName) + sink.writeChunk(probeName, byteArrayOf(1)) + sink.abortFile(probeName, "write probe complete") + FolderAccessStatus.Writable }.getOrDefault(FolderAccessStatus.Unavailable) private fun validateTreeUri(value: String): FolderAccessStatus { @@ -88,6 +132,134 @@ private class AndroidFileSystemService( } } +/** + * Writes into the shared system Downloads collection via MediaStore. + * + * Files appear in the user's Downloads app / Files UI the same way a browser + * download would. Nested relative paths become subfolders under Download/. + */ +private class AndroidMediaStoreDownloadsSink( + private val context: Context, +) : ReceiveOutputSink { + private data class PendingDocument( + val stream: OutputStream, + val uri: Uri, + ) + + private val pending = mutableMapOf() + private val resolver = context.contentResolver + + override fun startFile(relativePath: String) { + check(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + "MediaStore Downloads requires Android 10 or newer" + } + check(relativePath !in pending) { "Output stream is already open for $relativePath" } + val parts = relativePath.split('/').filter { it.isNotBlank() } + require(parts.isNotEmpty()) { "relative path must not be empty" } + val finalName = parts.last() + val relativeDir = mediaStoreRelativePath(parts.dropLast(1)) + check(!mediaStoreItemExists(finalName, relativeDir)) { + "Destination already exists: $relativePath" + } + + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, finalName) + put(MediaStore.MediaColumns.MIME_TYPE, mimeTypeFor(finalName)) + put(MediaStore.MediaColumns.RELATIVE_PATH, relativeDir) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + val uri = resolver.insert(downloadsCollection(), values) + ?: error("Could not create Downloads entry for $relativePath") + val stream = runCatching { resolver.openOutputStream(uri, "w") } + .getOrElse { error -> + resolver.delete(uri, null, null) + throw error + } ?: run { + resolver.delete(uri, null, null) + error("Could not open output stream for $relativePath") + } + pending[relativePath] = PendingDocument(stream, uri) + } + + override fun writeChunk(relativePath: String, bytes: ByteArray) { + val document = pending[relativePath] ?: error("Output stream is not open for $relativePath") + document.stream.write(bytes) + } + + override fun finishFile(relativePath: String) { + val document = pending.remove(relativePath) ?: error("Output stream is not open for $relativePath") + try { + document.stream.flush() + document.stream.close() + val published = ContentValues().apply { + put(MediaStore.MediaColumns.IS_PENDING, 0) + } + val updated = resolver.update(document.uri, published, null, null) + check(updated == 1) { "Could not publish received file $relativePath" } + } catch (error: Throwable) { + runCatching { document.stream.close() } + resolver.delete(document.uri, null, null) + throw error + } + } + + override fun abortFile(relativePath: String, reason: String) { + val document = pending.remove(relativePath) ?: return + runCatching { document.stream.close() } + resolver.delete(document.uri, null, null) + } + + private fun downloadsCollection(): Uri = + MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + + private fun mediaStoreRelativePath(subdirs: List): String { + val base = Environment.DIRECTORY_DOWNLOADS + return if (subdirs.isEmpty()) { + "$base/" + } else { + "$base/${subdirs.joinToString("/")}/" + } + } + + private fun mediaStoreItemExists(displayName: String, relativePath: String): Boolean { + val projection = arrayOf(MediaStore.MediaColumns._ID) + val selection = + "${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.RELATIVE_PATH}=?" + resolver.query( + downloadsCollection(), + projection, + selection, + arrayOf(displayName, relativePath), + null, + )?.use { cursor -> + return cursor.moveToFirst() + } + // Some providers omit the trailing slash; check the alternate form. + val altPath = relativePath.trimEnd('/') + if (altPath != relativePath) { + resolver.query( + downloadsCollection(), + projection, + selection, + arrayOf(displayName, altPath), + null, + )?.use { cursor -> + return cursor.moveToFirst() + } + } + return false + } + + private fun mimeTypeFor(fileName: String): String { + val extension = fileName.substringAfterLast('.', missingDelimiterValue = "").lowercase() + if (extension.isNotEmpty()) { + MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)?.let { return it } + URLConnection.guessContentTypeFromName(fileName)?.let { return it } + } + return "application/octet-stream" + } +} + private class AndroidTreeReceiveOutputSink( private val context: Context, private val treeUri: Uri, diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.android.kt new file mode 100644 index 0000000..4c13dd3 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.android.kt @@ -0,0 +1,92 @@ +package com.vnidrop.app.feature.receive + +import android.nfc.NfcAdapter +import android.nfc.tech.Ndef +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions +import com.google.mlkit.vision.codescanner.GmsBarcodeScanning + +@Composable +actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions { + val context = LocalContext.current + val activity = context as? ComponentActivity + var fileResult by remember { mutableStateOf<((Result) -> Unit)?>(null) } + val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + val callback = fileResult.also { fileResult = null } ?: return@rememberLauncherForActivityResult + if (uri == null) return@rememberLauncherForActivityResult + callback(runCatching { + val bytes = context.contentResolver.openInputStream(uri)?.use { it.readNBytes(MaxInvitationBytes + 1) } + ?: error("The invitation could not be opened") + decodeInvitationBytes(bytes) + }) + } + val nfcAdapter = remember(activity) { activity?.let(NfcAdapter::getDefaultAdapter) } + return remember(activity, filePicker, nfcAdapter) { + object : ReceiveInvitationActions { + override val fileAvailability = ReceiveMethodAvailability.Available + override val qrAvailability = if (activity != null) ReceiveMethodAvailability.Available else ReceiveMethodAvailability.Unavailable + override val nfcAvailability = if (nfcAdapter?.isEnabled == true) ReceiveMethodAvailability.Available else ReceiveMethodAvailability.Unavailable + + override fun pickInvitation(onResult: (Result) -> Unit) { + // Stop NFC reader before another acquisition path so only one method is active. + cancel() + fileResult = onResult + filePicker.launch(arrayOf(InvitationMimeType, "application/octet-stream", "text/plain", "*/*")) + } + + override fun scanQrCode(onResult: (Result) -> Unit) { + val host = activity ?: return onResult(Result.failure(UnsupportedOperationException("QR scanning is unavailable"))) + cancel() + val options = GmsBarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .enableAutoZoom() + .build() + GmsBarcodeScanning.getClient(host, options).startScan() + .addOnSuccessListener { barcode -> + val value = barcode.rawValue + onResult(if (value.isNullOrBlank()) Result.failure(IllegalArgumentException("The QR code is empty")) else Result.success(value)) + } + .addOnFailureListener { onResult(Result.failure(it)) } + .addOnCanceledListener { + onResult(Result.failure(IllegalStateException("QR scanning was cancelled"))) + } + } + + override fun readNfcInvitation(onResult: (Result) -> Unit) { + val host = activity ?: return onResult(Result.failure(UnsupportedOperationException("NFC is unavailable"))) + val adapter = nfcAdapter?.takeIf { it.isEnabled } + ?: return onResult(Result.failure(UnsupportedOperationException("NFC is unavailable"))) + cancel() + adapter.enableReaderMode(host, { tag -> + val result = runCatching { + val ndef = Ndef.get(tag) ?: error("This NFC tag does not contain an invitation") + ndef.connect() + try { + val record = ndef.ndefMessage?.records?.firstOrNull { record -> + record.tnf == android.nfc.NdefRecord.TNF_MIME_MEDIA && record.type.decodeToString() == InvitationMimeType + } ?: error("This NFC tag does not contain a VniDrop invitation") + decodeInvitationBytes(record.payload) + } finally { ndef.close() } + } + host.runOnUiThread { + adapter.disableReaderMode(host) + onResult(result) + } + }, NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_NFC_F or NfcAdapter.FLAG_READER_NFC_V, null) + } + + override fun cancel() { + activity?.let { nfcAdapter?.disableReaderMode(it) } + } + } + } +} diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index e96c089..0cffd33 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -79,6 +79,30 @@ Refuse Approve Receive + Files received directly on this device. + Receive your first file + Open a VniDrop invitation, scan its QR code, or read a nearby NFC tag. + Receive files + Received files + Clear history + Delete from receive history + Remove from history? + “%1$s” will be removed from VniDrop’s history. The downloaded file will remain on this device. + Clear receive history? + All completed, failed, and cancelled receives will be removed from VniDrop’s history. Downloaded files will remain on this device. + Receive history cleared. + How would you like to connect? + Choose the invitation method available to you. + Open a .vnd invitation + Choose an invitation saved or shared to this device. + Scan QR code + Use the camera to scan the sender’s VniDrop code. + Read NFC tag + Hold this device near the sender’s invitation tag. + Hold near the NFC tag… + Review transfer + VniDrop transfer + Transfer received. Inspect a ticket, request access, and stream files into the output directory. Ticket Ticket diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index d0eaa69..2db6bf5 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -19,7 +20,9 @@ import com.vnidrop.app.feature.app.AppViewModel import com.vnidrop.app.feature.app.AppGraphViewModel import com.vnidrop.app.feature.approvals.ApprovalModalHost import com.vnidrop.app.feature.receive.ReceiveRoute +import com.vnidrop.app.feature.receive.ReceiveFloatingAction import com.vnidrop.app.feature.receive.ReceiveViewModel +import com.vnidrop.app.feature.receive.ReceiveMethod import com.vnidrop.app.feature.send.SendRoute import com.vnidrop.app.feature.send.SendFloatingAction import com.vnidrop.app.feature.send.SendViewModel @@ -35,6 +38,9 @@ import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.state.windowClassFor import com.vnidrop.app.ui.theme.VniDropTheme import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull @Composable fun App(dependencies: AppDependencies) { @@ -69,8 +75,35 @@ fun App(dependencies: AppDependencies) { val appState by appViewModel.state.collectAsStateWithLifecycle() val sendState by sendViewModel.state.collectAsStateWithLifecycle() val sendCoreState by sendViewModel.coreState.collectAsStateWithLifecycle() + val receiveState by receiveViewModel.state.collectAsStateWithLifecycle() + val receiveCoreState by receiveViewModel.coreState.collectAsStateWithLifecycle() val approvalState by graph.approvalCoordinator.state.collectAsStateWithLifecycle() val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(dependencies.externalInvitations, appViewModel, receiveViewModel) { + dependencies.externalInvitations.invitations.collect { invitation -> + appViewModel.selectDestination(AppDestination.Receive) + if (invitation.isSuccess) { + // Cold-open can race app startup. Wait for core before inspecting so + // the ticket is not dropped as "not initialized", but do not block + // forever if initialization failed. + val ready = withTimeoutOrNull(30_000) { + receiveViewModel.coreState.filter { it.isInitialized }.first() + } + if (ready == null) { + receiveViewModel.onInvitationResult( + ReceiveMethod.InvitationFile, + Result.failure(IllegalStateException("VniDrop is still starting up. Open the invitation again in a moment.")), + ) + return@collect + } + // Avoid clobbering an in-flight inspection or receive. + receiveViewModel.state.filter { state -> + !state.isInspecting && !state.isReceiving && state.ticket.isBlank() + }.first() + } + receiveViewModel.onInvitationResult(ReceiveMethod.InvitationFile, invitation) + } + } DisposableEffect(lifecycleOwner, graph, settingsViewModel) { val observer = LifecycleEventObserver { _, event -> when (event) { @@ -97,6 +130,10 @@ fun App(dependencies: AppDependencies) { sendCoreState.transfers.any { it.transferId == selectedId } } != true && sendCoreState.transfers.any { it.direction == TransferDirection.Send } + val showReceiveAction = appState.destination == AppDestination.Receive && + windowClass == WindowClass.Phone && + !receiveState.isAcquisitionOpen && + receiveCoreState.transfers.any { it.direction == TransferDirection.Receive } AppShell( modifier = Modifier.fillMaxSize(), selectedDestination = appState.destination, @@ -112,13 +149,20 @@ fun App(dependencies: AppDependencies) { modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), ) } + } else if (showReceiveAction) { + { + ReceiveFloatingAction( + onClick = receiveViewModel::openAcquisition, + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), + ) + } } else { null }, ) { when (appState.destination) { AppDestination.Send -> SendRoute(sendViewModel, windowClass) - AppDestination.Receive -> ScreenScrollContainer { ReceiveRoute(receiveViewModel) } + AppDestination.Receive -> ReceiveRoute(receiveViewModel, windowClass) AppDestination.Settings -> ScreenScrollContainer { SettingsRoute(settingsViewModel, windowClass) } } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt index 23b146a..647279a 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt @@ -2,6 +2,7 @@ package com.vnidrop.app import com.vnidrop.app.core.FileSystemService import com.vnidrop.app.notifications.LocalNotificationService +import com.vnidrop.app.feature.receive.ExternalInvitationController data class PlatformEnvironment( val name: String, @@ -27,4 +28,5 @@ data class AppDependencies( val deviceInfoProvider: DeviceInfoProvider, val fileSystemService: FileSystemService, val localNotificationService: LocalNotificationService, + val externalInvitations: ExternalInvitationController, ) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt index 2e62e26..1791ee5 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt @@ -146,6 +146,7 @@ interface CoreGateway { suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result suspend fun cancel(transferId: ULong): Result suspend fun delete(transferId: ULong): Result + suspend fun clearReceiveHistory(): Result suspend fun receiverRequests(transferId: ULong): Result> suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null): Result suspend fun refresh(): Result diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index 4b0867f..ce9ba71 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -173,6 +173,12 @@ class CoreRepository( _signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId)) } + override suspend fun clearReceiveHistory(): Result = runCore { + val deleted = requireCore().deleteReceiveHistory() + refreshSnapshot() + deleted + } + override suspend fun receiverRequests(transferId: ULong): Result> = runCore { requireCore().listReceiverRequests(transferId).map(ReceiverRequest::toModel) } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt index 0b849db..1423a90 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt @@ -5,10 +5,15 @@ import uniffi.vnidrop.ReceiveOutputSink enum class ReceiveFolderKind { FileSystemPath, + /** Shared system Downloads via MediaStore (Android 10+). */ + AndroidPublicDownloads, AndroidTreeUri, IosSecurityScopedUrl, } +/** Stable token stored in preferences for [ReceiveFolderKind.AndroidPublicDownloads]. */ +const val AndroidPublicDownloadsToken = "media-store:downloads" + data class ReceiveFolder( val kind: ReceiveFolderKind, val value: String, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationController.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationController.kt new file mode 100644 index 0000000..5a8844a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationController.kt @@ -0,0 +1,52 @@ +package com.vnidrop.app.feature.receive + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow + +const val VniDropInvitationMimeType = "application/vnd.vnidrop.transfer" +const val VniDropInvitationExtension = "vnd" +const val MaxVniDropInvitationBytes = 64 * 1024 + +/** + * Buffered ingress for invitation documents opened by a platform host. + * + * Hosts can submit before Compose is attached during a cold launch. Each + * document is then consumed exactly once by the app-level receive workflow. + */ +class ExternalInvitationController { + // OS document-open dispatch can be triggered by another process. Keep the + // cold-launch queue bounded so repeated intents cannot grow memory forever. + private val pending = Channel>(capacity = 16) + val invitations: Flow> = pending.receiveAsFlow() + + fun openInvitation(raw: String) { + pending.trySend(validateInvitation(raw)) + } + + fun reportOpenFailure(message: String) { + pending.trySend(Result.failure(IllegalArgumentException(message))) + } +} + +internal fun validateInvitation(raw: String): Result = runCatching { + require(raw.isNotBlank()) { "The invitation is empty" } + require(raw.encodeToByteArray().size <= MaxVniDropInvitationBytes) { "The invitation is too large" } + raw +} + +/** + * Decode invitation document bytes as strict UTF-8 text. + * + * Hosts often receive invitation files as opaque binary streams. Reject payloads + * that are not valid UTF-8 so binary junk never reaches ticket inspection. + */ +fun decodeInvitationBytes(bytes: ByteArray): String { + require(bytes.isNotEmpty()) { "The invitation is empty" } + require(bytes.size <= MaxVniDropInvitationBytes) { "The invitation is too large" } + val text = bytes.decodeToString() + // decodeToString() replaces malformed sequences; require a lossless round-trip. + require(text.encodeToByteArray().contentEquals(bytes)) { "The invitation is not valid text" } + require(text.isNotBlank()) { "The invitation is empty" } + return text +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.kt new file mode 100644 index 0000000..ba7caae --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.kt @@ -0,0 +1,24 @@ +package com.vnidrop.app.feature.receive + +import androidx.compose.runtime.Composable + +enum class ReceiveMethod { InvitationFile, QrCode, Nfc } + +enum class ReceiveMethodAvailability { Available, Unavailable, Hidden } + +interface ReceiveInvitationActions { + val fileAvailability: ReceiveMethodAvailability + val qrAvailability: ReceiveMethodAvailability + val nfcAvailability: ReceiveMethodAvailability + + fun pickInvitation(onResult: (Result) -> Unit) + fun scanQrCode(onResult: (Result) -> Unit) + fun readNfcInvitation(onResult: (Result) -> Unit) + fun cancel() +} + +@Composable +expect fun rememberReceiveInvitationActions(): ReceiveInvitationActions + +internal const val InvitationMimeType = VniDropInvitationMimeType +internal const val MaxInvitationBytes = MaxVniDropInvitationBytes diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveRoute.kt index c6f2df9..1b2f7dc 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveRoute.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveRoute.kt @@ -1,19 +1,35 @@ package com.vnidrop.app.feature.receive import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vnidrop.app.ui.state.WindowClass @Composable -fun ReceiveRoute(viewModel: ReceiveViewModel) { +fun ReceiveRoute(viewModel: ReceiveViewModel, windowClass: WindowClass) { val state by viewModel.state.collectAsStateWithLifecycle() val coreState by viewModel.coreState.collectAsStateWithLifecycle() + val actions = rememberReceiveInvitationActions() + DisposableEffect(actions) { onDispose(actions::cancel) } + ReceiveScreen( coreState = coreState, state = state, - onTicketChanged = viewModel::setTicket, + windowClass = windowClass, + actions = actions, + onOpenAcquisition = viewModel::openAcquisition, + onDismissAcquisition = { + actions.cancel() + viewModel.dismissAcquisition() + }, onReceiverNameChanged = viewModel::setReceiverName, - onInspectTicket = viewModel::inspectTicket, + onInvitationResult = viewModel::onInvitationResult, + onWaitingForNfc = viewModel::setWaitingForNfc, onReceive = viewModel::receive, + onRequestDeleteHistoryItem = viewModel::requestDeleteHistoryItem, + onRequestClearHistory = viewModel::requestClearHistory, + onDismissHistoryDelete = viewModel::dismissHistoryDelete, + onConfirmHistoryDelete = viewModel::confirmHistoryDelete, ) } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt index cb8dc74..74ca12c 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt @@ -1,76 +1,323 @@ package com.vnidrop.app.feature.receive +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathBuilder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vnidrop.app.core.CoreState import com.vnidrop.app.core.FolderAccessStatus -import com.vnidrop.app.ui.components.AppCard +import com.vnidrop.app.core.Transfer +import com.vnidrop.app.core.TransferDirection +import com.vnidrop.app.ui.components.AdaptiveDrawer +import com.vnidrop.app.ui.components.DestructiveButton +import com.vnidrop.app.ui.components.DestructiveQuietButton import com.vnidrop.app.ui.components.Field -import com.vnidrop.app.ui.components.MetadataRow import com.vnidrop.app.ui.components.PrimaryButton import com.vnidrop.app.ui.components.SecondaryButton -import com.vnidrop.app.ui.screens.ProgressSection -import com.vnidrop.app.ui.screens.ScreenHeader -import com.vnidrop.app.ui.screens.TicketInspectionCard +import com.vnidrop.app.ui.state.WindowClass +import com.vnidrop.app.ui.state.displayNameForStatus +import com.vnidrop.app.ui.state.formatBytes +import com.vnidrop.app.ui.theme.LocalVniDropColors import org.jetbrains.compose.resources.stringResource -import vnidrop.shared.generated.resources.Res -import vnidrop.shared.generated.resources.button_inspect_ticket -import vnidrop.shared.generated.resources.button_receive -import vnidrop.shared.generated.resources.button_receiving -import vnidrop.shared.generated.resources.field_output_directory -import vnidrop.shared.generated.resources.field_receiver_name -import vnidrop.shared.generated.resources.field_ticket -import vnidrop.shared.generated.resources.folder_status_permission_required -import vnidrop.shared.generated.resources.folder_status_unavailable -import vnidrop.shared.generated.resources.folder_status_writable -import vnidrop.shared.generated.resources.metadata_status -import vnidrop.shared.generated.resources.receive_subtitle -import vnidrop.shared.generated.resources.receive_title -import vnidrop.shared.generated.resources.ticket_card_title +import vnidrop.shared.generated.resources.* + +@Composable +fun ReceiveFloatingAction(onClick: () -> Unit, modifier: Modifier = Modifier) { + FloatingActionButton( + onClick = onClick, + modifier = modifier, + containerColor = LocalVniDropColors.current.brandButton, + contentColor = Color.White, + ) { Icon(ReceiveIcons.Download, stringResource(Res.string.button_receive_files)) } +} @Composable fun ReceiveScreen( coreState: CoreState, state: ReceiveState, - onTicketChanged: (String) -> Unit, + windowClass: WindowClass, + actions: ReceiveInvitationActions, + onOpenAcquisition: () -> Unit, + onDismissAcquisition: () -> Unit, onReceiverNameChanged: (String) -> Unit, - onInspectTicket: () -> Unit, + onInvitationResult: (ReceiveMethod, Result) -> Unit, + onWaitingForNfc: (Boolean) -> Unit, onReceive: () -> Unit, + onRequestDeleteHistoryItem: (ULong) -> Unit, + onRequestClearHistory: () -> Unit, + onDismissHistoryDelete: () -> Unit, + onConfirmHistoryDelete: () -> Unit, ) { - Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { - ScreenHeader(stringResource(Res.string.receive_title), stringResource(Res.string.receive_subtitle)) - AppCard(title = stringResource(Res.string.ticket_card_title)) { - Field(state.ticket, onTicketChanged, stringResource(Res.string.field_ticket), minLines = 4) - MetadataRow( - stringResource(Res.string.field_output_directory), - state.receiveFolder?.displayName?.ifBlank { state.outputDirectory } ?: state.outputDirectory, - ) - MetadataRow(stringResource(Res.string.metadata_status), state.folderAccessStatus.displayName()) - Field(state.receiverName, onReceiverNameChanged, stringResource(Res.string.field_receiver_name)) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - SecondaryButton( - stringResource(Res.string.button_inspect_ticket), - onClick = onInspectTicket, - enabled = state.canInspect(coreState.isInitialized), + val transfers = coreState.transfers.filter { it.direction == TransferDirection.Receive } + val deletableTransfers = transfers.filter { it.status.isTerminalReceiveHistory() } + LazyColumn( + modifier = Modifier.fillMaxSize().statusBarsPadding(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { ReceiveHeader(transfers.isNotEmpty(), windowClass, onOpenAcquisition) } + if (transfers.isEmpty()) item { ReceiveEmptyState(onOpenAcquisition) } + else { + item { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(Res.string.receive_history_title), modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + if (deletableTransfers.isNotEmpty()) DestructiveQuietButton(stringResource(Res.string.receive_clear_history), onClick = onRequestClearHistory) + } + } + items(transfers, key = Transfer::localId) { transfer -> + ReceiveTransferRow(transfer, onDelete = { onRequestDeleteHistoryItem(transfer.transferId) }) + } + } + } + + if (state.isAcquisitionOpen) { + AdaptiveDrawer(windowClass, onDismissAcquisition) { + if (state.ticket.isBlank()) { + ReceiveMethodPanel( + actions = actions, + isWaitingForNfc = state.isWaitingForNfc, + onResult = onInvitationResult, + onWaitingForNfc = onWaitingForNfc, ) - PrimaryButton( - if (state.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive), - onClick = onReceive, - enabled = state.canReceive(coreState.isInitialized), + } else { + InvitationReviewPanel( + state = state, + coreInitialized = coreState.isInitialized, + onReceiverNameChanged = onReceiverNameChanged, + onReceive = onReceive, ) } } - coreState.lastInspection?.let { TicketInspectionCard(it) } - ProgressSection(coreState) + } + + state.historyDeleteTarget?.let { target -> + val transferName = (target as? ReceiveHistoryDeleteTarget.Transfer)?.let { selected -> + transfers.firstOrNull { it.transferId == selected.transferId }?.transferName + } + AdaptiveDrawer(windowClass, onDismissHistoryDelete) { + ReceiveHistoryDeletePanel( + clearAll = target == ReceiveHistoryDeleteTarget.All, + transferName = transferName, + isDeleting = state.isDeletingHistory, + onCancel = onDismissHistoryDelete, + onConfirm = onConfirmHistoryDelete, + ) + } } } @Composable -private fun FolderAccessStatus.displayName(): String = when (this) { - FolderAccessStatus.Writable -> stringResource(Res.string.folder_status_writable) - FolderAccessStatus.PermissionRequired -> stringResource(Res.string.folder_status_permission_required) - FolderAccessStatus.Unavailable -> stringResource(Res.string.folder_status_unavailable) +private fun ReceiveHeader(showAction: Boolean, windowClass: WindowClass, onOpen: () -> Unit) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(Res.string.receive_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text(stringResource(Res.string.receive_new_subtitle), color = LocalVniDropColors.current.foregroundLighter) + } + if (showAction && windowClass != WindowClass.Phone) { + Spacer(Modifier.width(16.dp)) + PrimaryButton(stringResource(Res.string.button_receive_files), onClick = onOpen) + } + } } + +@Composable +private fun ReceiveEmptyState(onOpen: () -> Unit) { + val colors = LocalVniDropColors.current + Column( + Modifier.fillMaxWidth().heightIn(min = 430.dp).padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Box(Modifier.size(68.dp).background(colors.brandLink.copy(alpha = 0.12f), RoundedCornerShape(20.dp)), contentAlignment = Alignment.Center) { + Icon(ReceiveIcons.Download, null, tint = colors.brandLink, modifier = Modifier.size(30.dp)) + } + Text(stringResource(Res.string.receive_empty_title), modifier = Modifier.padding(top = 22.dp), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Text( + stringResource(Res.string.receive_empty_body), + modifier = Modifier.padding(top = 8.dp).widthIn(max = 480.dp), + color = colors.foregroundLighter, + textAlign = TextAlign.Center, + ) + PrimaryButton(stringResource(Res.string.button_receive_files), onClick = onOpen, modifier = Modifier.padding(top = 22.dp)) + } +} + +@Composable +private fun ReceiveMethodPanel( + actions: ReceiveInvitationActions, + isWaitingForNfc: Boolean, + onResult: (ReceiveMethod, Result) -> Unit, + onWaitingForNfc: (Boolean) -> Unit, +) { + Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(Res.string.receive_choose_method_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(stringResource(Res.string.receive_choose_method_body), color = LocalVniDropColors.current.foregroundLighter) + ReceiveMethodRow( + ReceiveIcons.File, stringResource(Res.string.receive_method_file), stringResource(Res.string.receive_method_file_description), + actions.fileAvailability, + ) { actions.pickInvitation { onResult(ReceiveMethod.InvitationFile, it) } } + if (actions.qrAvailability != ReceiveMethodAvailability.Hidden) ReceiveMethodRow( + ReceiveIcons.Scan, stringResource(Res.string.receive_method_scan), stringResource(Res.string.receive_method_scan_description), + actions.qrAvailability, + ) { actions.scanQrCode { onResult(ReceiveMethod.QrCode, it) } } + if (actions.nfcAvailability != ReceiveMethodAvailability.Hidden) ReceiveMethodRow( + ReceiveIcons.Nfc, + if (isWaitingForNfc) stringResource(Res.string.receive_nfc_waiting) else stringResource(Res.string.receive_method_nfc), + stringResource(Res.string.receive_method_nfc_description), + if (isWaitingForNfc) ReceiveMethodAvailability.Unavailable else actions.nfcAvailability, + ) { + onWaitingForNfc(true) + actions.readNfcInvitation { onResult(ReceiveMethod.Nfc, it) } + } + } +} + +@Composable +private fun ReceiveMethodRow(icon: ImageVector, title: String, description: String, availability: ReceiveMethodAvailability, onClick: () -> Unit) { + val enabled = availability == ReceiveMethodAvailability.Available + Surface( + modifier = Modifier.fillMaxWidth().clickable(enabled = enabled, onClick = onClick), + shape = RoundedCornerShape(14.dp), + color = LocalVniDropColors.current.backgroundSurface200, + ) { + Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(icon, null, tint = if (enabled) LocalVniDropColors.current.brandLink else LocalVniDropColors.current.foregroundLighter, modifier = Modifier.size(24.dp)) + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, fontWeight = FontWeight.SemiBold) + Text(description, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall) + } + if (availability == ReceiveMethodAvailability.Unavailable) Text(stringResource(Res.string.value_unavailable), color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.labelSmall) + } + } +} + +@Composable +private fun InvitationReviewPanel(state: ReceiveState, coreInitialized: Boolean, onReceiverNameChanged: (String) -> Unit, onReceive: () -> Unit) { + Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text(stringResource(Res.string.receive_review_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + if (state.isInspecting) Box(Modifier.fillMaxWidth().padding(40.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + state.inspection?.let { inspection -> + val metadata = inspection.metadata + Surface(shape = RoundedCornerShape(14.dp), color = LocalVniDropColors.current.backgroundSurface200) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(metadata?.transferName ?: stringResource(Res.string.receive_unknown_transfer), fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis) + if (metadata != null) Text("${metadata.fileCount} ${stringResource(Res.string.metadata_files).lowercase()} · ${formatBytes(metadata.totalSize)}", color = LocalVniDropColors.current.foregroundLighter) + } + } + Field(state.receiverName, onReceiverNameChanged, stringResource(Res.string.field_receiver_name)) + Text( + state.receiveFolder?.displayName ?: stringResource(Res.string.value_unavailable), + color = if (state.folderAccessStatus == FolderAccessStatus.Writable) LocalVniDropColors.current.foregroundLight else LocalVniDropColors.current.destructiveDefault, + style = MaterialTheme.typography.bodySmall, + ) + PrimaryButton( + if (state.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive), + onClick = onReceive, + modifier = Modifier.fillMaxWidth(), + enabled = state.canReceive(coreInitialized), + ) + } + } +} + +@Composable +private fun ReceiveTransferRow(transfer: Transfer, onDelete: () -> Unit) { + Surface(Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp), color = LocalVniDropColors.current.backgroundSurface200) { + Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(44.dp).background(LocalVniDropColors.current.backgroundSurface300, RoundedCornerShape(10.dp)), contentAlignment = Alignment.Center) { + Icon(ReceiveIcons.File, null, tint = LocalVniDropColors.current.foregroundLighter) + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(transfer.transferName ?: stringResource(Res.string.receive_unknown_transfer), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("${formatBytes(transfer.totalSize)} · ${displayNameForStatus(transfer.status)}", color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall) + } + if (transfer.status.isTerminalReceiveHistory()) { + IconButton(onClick = onDelete) { + Icon(ReceiveIcons.Trash, stringResource(Res.string.receive_delete_history_item), tint = LocalVniDropColors.current.destructiveDefault) + } + } + } + } +} + +@Composable +private fun ReceiveHistoryDeletePanel( + clearAll: Boolean, + transferName: String?, + isDeleting: Boolean, + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text( + stringResource(if (clearAll) Res.string.receive_clear_history_title else Res.string.receive_delete_history_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + if (clearAll) stringResource(Res.string.receive_clear_history_description) + else stringResource(Res.string.receive_delete_history_description, transferName ?: stringResource(Res.string.receive_unknown_transfer)), + color = LocalVniDropColors.current.foregroundLighter, + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)) { + SecondaryButton(stringResource(Res.string.button_cancel), onClick = onCancel, enabled = !isDeleting) + DestructiveButton( + if (isDeleting) stringResource(Res.string.transfer_deleting) + else stringResource(if (clearAll) Res.string.receive_clear_history else Res.string.button_delete_transfer), + onClick = onConfirm, + enabled = !isDeleting, + ) + } + } +} + +private object ReceiveIcons { + val Download = lineIcon("Download") { moveTo(12f, 3f); lineTo(12f, 15f); moveTo(7f, 10f); lineTo(12f, 15f); lineTo(17f, 10f); moveTo(4f, 20f); lineTo(20f, 20f) } + val File = lineIcon("File") { moveTo(14f, 2f); lineTo(6f, 2f); lineTo(6f, 22f); lineTo(18f, 22f); lineTo(18f, 6f); close(); moveTo(14f, 2f); lineTo(14f, 6f); lineTo(18f, 6f) } + val Scan = lineIcon("Scan") { moveTo(3f, 8f); lineTo(3f, 3f); lineTo(8f, 3f); moveTo(16f, 3f); lineTo(21f, 3f); lineTo(21f, 8f); moveTo(21f, 16f); lineTo(21f, 21f); lineTo(16f, 21f); moveTo(8f, 21f); lineTo(3f, 21f); lineTo(3f, 16f); moveTo(7f, 12f); lineTo(17f, 12f) } + val Nfc = lineIcon("Nfc") { moveTo(6f, 8f); curveTo(10f, 12f, 10f, 12f, 6f, 16f); moveTo(10f, 5f); curveTo(17f, 12f, 17f, 12f, 10f, 19f); moveTo(14f, 2f); curveTo(24f, 12f, 24f, 12f, 14f, 22f) } + val Trash = lineIcon("Delete") { moveTo(4f, 7f); lineTo(20f, 7f); moveTo(9f, 3f); lineTo(15f, 3f); lineTo(16f, 7f); moveTo(7f, 7f); lineTo(8f, 21f); lineTo(16f, 21f); lineTo(17f, 7f); moveTo(10f, 11f); lineTo(10f, 17f); moveTo(14f, 11f); lineTo(14f, 17f) } +} + +private fun lineIcon(name: String, block: PathBuilder.() -> Unit) = ImageVector.Builder(name, 24.dp, 24.dp, 24f, 24f).apply { + path(fill = SolidColor(Color.Transparent), stroke = SolidColor(Color.Black), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round, pathFillType = PathFillType.NonZero, pathBuilder = block) +}.build() diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt index 588e3cb..eb8831a 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt @@ -7,26 +7,46 @@ import com.vnidrop.app.core.FileSystemService import com.vnidrop.app.core.FolderAccessStatus import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolderKind +import com.vnidrop.app.core.TicketInspectionModel +import com.vnidrop.app.core.TransferDirection +import com.vnidrop.app.core.TransferStatus import com.vnidrop.app.preferences.PreferencesRepository +import com.vnidrop.app.ui.feedback.UiMessage import com.vnidrop.app.ui.feedback.UiMessageController +import com.vnidrop.app.ui.feedback.UiMessageTone +import com.vnidrop.app.ui.feedback.UiText import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.receive_completed +import vnidrop.shared.generated.resources.receive_history_cleared +import vnidrop.shared.generated.resources.transfer_deleted + +sealed interface ReceiveHistoryDeleteTarget { + data class Transfer(val transferId: ULong) : ReceiveHistoryDeleteTarget + data object All : ReceiveHistoryDeleteTarget +} data class ReceiveState( + val isAcquisitionOpen: Boolean = false, val ticket: String = "", - val outputDirectory: String = "", + val method: ReceiveMethod? = null, + val inspection: TicketInspectionModel? = null, val receiverName: String = "", val receiveFolder: ReceiveFolder? = null, val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable, + val isInspecting: Boolean = false, val isReceiving: Boolean = false, + val isWaitingForNfc: Boolean = false, + val historyDeleteTarget: ReceiveHistoryDeleteTarget? = null, + val isDeletingHistory: Boolean = false, ) { - fun canInspect(coreInitialized: Boolean): Boolean = coreInitialized && ticket.isNotBlank() fun canReceive(coreInitialized: Boolean): Boolean = - coreInitialized && ticket.isNotBlank() && outputDirectory.isNotBlank() && - folderAccessStatus == FolderAccessStatus.Writable && !isReceiving + coreInitialized && ticket.isNotBlank() && inspection != null && + folderAccessStatus == FolderAccessStatus.Writable && !isReceiving && !isInspecting } class ReceiveViewModel( @@ -47,7 +67,6 @@ class ReceiveViewModel( current.copy( receiverName = current.receiverName.ifBlank { preferences.username }, receiveFolder = preferences.receiveFolder, - outputDirectory = preferences.receiveFolder.value, folderAccessStatus = status, ) } @@ -55,14 +74,58 @@ class ReceiveViewModel( } } - fun setTicket(value: String) = _state.update { it.copy(ticket = value) } - fun setOutputDirectory(value: String) = _state.update { it.copy(outputDirectory = value) } + fun openAcquisition() = _state.update { it.copy(isAcquisitionOpen = true) } + fun dismissAcquisition() { + if (!_state.value.isReceiving && !_state.value.isInspecting) resetAcquisition() + } fun setReceiverName(value: String) = _state.update { it.copy(receiverName = value) } + fun setWaitingForNfc(waiting: Boolean) = _state.update { it.copy(isWaitingForNfc = waiting) } + fun requestDeleteHistoryItem(transferId: ULong) { + val canDelete = coreState.value.transfers.any { transfer -> + transfer.transferId == transferId && transfer.direction == TransferDirection.Receive && transfer.status.isTerminalReceiveHistory() + } + if (canDelete) _state.update { it.copy(historyDeleteTarget = ReceiveHistoryDeleteTarget.Transfer(transferId)) } + } + fun requestClearHistory() { + if (coreState.value.transfers.any { it.direction == TransferDirection.Receive && it.status.isTerminalReceiveHistory() }) { + _state.update { it.copy(historyDeleteTarget = ReceiveHistoryDeleteTarget.All) } + } + } + fun dismissHistoryDelete() { + if (!_state.value.isDeletingHistory) _state.update { it.copy(historyDeleteTarget = null) } + } + fun confirmHistoryDelete() { + val target = _state.value.historyDeleteTarget ?: return + if (_state.value.isDeletingHistory) return + viewModelScope.launch { + _state.update { it.copy(isDeletingHistory = true) } + val result = when (target) { + is ReceiveHistoryDeleteTarget.Transfer -> repository.delete(target.transferId).map { Unit } + ReceiveHistoryDeleteTarget.All -> repository.clearReceiveHistory().map { Unit } + } + result.fold( + onSuccess = { + _state.update { it.copy(historyDeleteTarget = null, isDeletingHistory = false) } + val message = when (target) { + is ReceiveHistoryDeleteTarget.Transfer -> Res.string.transfer_deleted + ReceiveHistoryDeleteTarget.All -> Res.string.receive_history_cleared + } + messages.tryShow(UiMessage(UiText.Resource(message), UiMessageTone.Success)) + }, + onFailure = { error -> + _state.update { it.copy(isDeletingHistory = false) } + messages.error(error) + }, + ) + } + } - fun inspectTicket() { - val current = state.value - if (!current.canInspect(coreState.value.isInitialized)) return - viewModelScope.launch { repository.inspectTicket(current.ticket).onFailure(messages::error) } + fun onInvitationResult(method: ReceiveMethod, result: Result) { + _state.update { it.copy(isWaitingForNfc = false) } + result.fold( + onSuccess = { raw -> inspectInvitation(method, raw) }, + onFailure = messages::error, + ) } fun receive() { @@ -71,21 +134,64 @@ class ReceiveViewModel( if (!current.canReceive(coreState.value.isInitialized)) return viewModelScope.launch { _state.update { it.copy(isReceiving = true) } - try { - val outputSink = fileSystemService.createReceiveOutputSink(folder) - val result = when { - outputSink != null -> repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName) - folder.kind == ReceiveFolderKind.IosSecurityScopedUrl -> repository.receiveIntoSecurityScopedDirectory( - current.ticket, - folder.value, - current.receiverName, - ) - else -> repository.receive(current.ticket, current.outputDirectory, current.receiverName) - } - result.onFailure(messages::error) - } finally { - _state.update { it.copy(isReceiving = false) } + val outputSink = fileSystemService.createReceiveOutputSink(folder) + val result = when { + outputSink != null -> repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName) + folder.kind == ReceiveFolderKind.IosSecurityScopedUrl -> repository.receiveIntoSecurityScopedDirectory( + current.ticket, + folder.value, + current.receiverName, + ) + else -> repository.receive(current.ticket, folder.value, current.receiverName) } + result.fold( + onSuccess = { + resetAcquisition() + messages.tryShow(UiMessage(UiText.Resource(Res.string.receive_completed), UiMessageTone.Success)) + }, + onFailure = { error -> + _state.update { it.copy(isReceiving = false) } + messages.error(error) + }, + ) } } + + private fun inspectInvitation(method: ReceiveMethod, raw: String) { + val ticket = raw.trim() + if (ticket.isBlank()) return messages.error(IllegalArgumentException("The invitation is empty")) + viewModelScope.launch { + _state.update { + it.copy( + isAcquisitionOpen = true, + ticket = ticket, + method = method, + inspection = null, + isInspecting = true, + ) + } + repository.inspectTicket(ticket).fold( + onSuccess = { inspection -> _state.update { it.copy(inspection = inspection, isInspecting = false) } }, + onFailure = { error -> + _state.update { it.copy(ticket = "", method = null, inspection = null, isInspecting = false) } + messages.error(error) + }, + ) + } + } + + private fun resetAcquisition() = _state.update { + it.copy( + isAcquisitionOpen = false, + ticket = "", + method = null, + inspection = null, + isInspecting = false, + isReceiving = false, + isWaitingForNfc = false, + ) + } } + +internal fun TransferStatus.isTerminalReceiveHistory(): Boolean = + this == TransferStatus.Done || this == TransferStatus.Failed || this == TransferStatus.Cancelled diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt index c9eaabd..0c64aa3 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt @@ -47,14 +47,7 @@ class AppPreferencesRepository( .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, - ), + receiveFolder = resolveReceiveFolder(prefs, defaults.receiveFolder), themeMode = prefs[PreferenceKeys.ThemeMode]?.let { themeModeOrNull(it) } ?: defaults.themeMode, notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled, ) @@ -105,6 +98,32 @@ private object PreferenceKeys { val NotificationsEnabled = booleanPreferencesKey("notifications_enabled") } +private fun resolveReceiveFolder(prefs: Preferences, defaults: ReceiveFolder): ReceiveFolder { + val kind = prefs[PreferenceKeys.ReceiveFolderKind]?.let { receiveFolderKindOrNull(it) } + ?: defaults.kind + val value = prefs[PreferenceKeys.ReceiveFolderValue]?.takeIf { it.isNotBlank() } + ?: defaults.value + val displayName = prefs[PreferenceKeys.ReceiveFolderDisplayName]?.takeIf { it.isNotBlank() } + ?: defaults.displayName + // Older Android builds defaulted to app-private "Downloads" paths that are + // invisible in the system Downloads UI. Promote those back to the shared + // public Downloads default so receive matches desktop expectations. + if (kind == ReceiveFolderKind.FileSystemPath && isLegacyAndroidAppDownloadsPath(value)) { + return defaults + } + return ReceiveFolder(kind = kind, value = value, displayName = displayName) +} + +private fun isLegacyAndroidAppDownloadsPath(path: String): Boolean { + // Typical: /storage/emulated/0/Android/data//files/Download[s] + val normalized = path.replace('\\', '/') + return normalized.contains("/Android/data/") && + (normalized.endsWith("/files/Download") || + normalized.endsWith("/files/Downloads") || + normalized.contains("/files/Download/") || + normalized.contains("/files/Downloads/")) +} + private fun receiveFolderKindOrNull(raw: String): ReceiveFolderKind? = runCatching { ReceiveFolderKind.valueOf(raw) }.getOrNull() diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt index 730f9f9..980af93 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt @@ -41,6 +41,18 @@ fun QuietButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier } } +@Composable +fun DestructiveQuietButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) { + TextButton( + onClick = onClick, + enabled = enabled, + modifier = modifier.heightIn(min = 40.dp), + colors = ButtonDefaults.textButtonColors(contentColor = LocalVniDropColors.current.destructiveDefault), + ) { + Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + @Composable fun DestructiveButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) { Button( diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index 2755739..e926198 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -7,7 +7,11 @@ import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolderKind import com.vnidrop.app.core.Share import com.vnidrop.app.core.ShareAccessPolicy +import com.vnidrop.app.core.Transfer +import com.vnidrop.app.core.TransferDirection +import com.vnidrop.app.core.TransferStatus import com.vnidrop.app.feature.app.AppViewModel +import com.vnidrop.app.feature.receive.ReceiveHistoryDeleteTarget import com.vnidrop.app.feature.receive.ReceiveViewModel import com.vnidrop.app.feature.send.SendViewModel import com.vnidrop.app.feature.settings.SettingsViewModel @@ -227,20 +231,236 @@ class ViewModelsTest { @Test fun receiveViewModelBuildsStateFromPreferences() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - val core = FakeCoreGateway().apply { mutableState.value = mutableState.value.copy(isInitialized = true) } + val core = FakeCoreGateway().apply { + mutableState.value = mutableState.value.copy(isInitialized = true) + inspectionResult = Result.success(com.vnidrop.app.core.TicketInspectionModel( + kind = "vnidrop", + blobTicket = "blob", + metadata = com.vnidrop.app.core.TransferMetadataModel(1UL, "Photo", null, "hash", 1UL, 42UL), + )) + } val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController()) advanceUntilIdle() - viewModel.setTicket("ticket") + viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.InvitationFile, Result.success("ticket")) + advanceUntilIdle() assertTrue(viewModel.state.value.canReceive(coreInitialized = true)) assertEquals("Receiver", viewModel.state.value.receiverName) } + @Test + fun receiveViewModelDeletesOneTerminalHistoryItem() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + mutableState.value = CoreState(isInitialized = true, transfers = listOf(receivedTransfer(21UL, TransferStatus.Done))) + } + val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController()) + advanceUntilIdle() + + viewModel.requestDeleteHistoryItem(21UL) + viewModel.confirmHistoryDelete() + advanceUntilIdle() + + assertEquals(listOf(21UL), core.deletedTransfers) + assertEquals(null, viewModel.state.value.historyDeleteTarget) + } + + @Test + fun receiveViewModelClearHistoryUsesAtomicCoreOperationAndKeepsActiveReceive() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + clearReceiveHistoryResult = Result.success(2UL) + mutableState.value = CoreState( + isInitialized = true, + transfers = listOf( + receivedTransfer(21UL, TransferStatus.Done), + receivedTransfer(22UL, TransferStatus.Failed), + receivedTransfer(23UL, TransferStatus.Receiving), + ), + ) + } + val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController()) + advanceUntilIdle() + + viewModel.requestClearHistory() + assertEquals(ReceiveHistoryDeleteTarget.All, viewModel.state.value.historyDeleteTarget) + viewModel.confirmHistoryDelete() + advanceUntilIdle() + + assertEquals(1, core.clearReceiveHistoryCount) + assertEquals(listOf(23UL), core.state.value.transfers.map(Transfer::transferId)) + } + + @Test + fun receiveViewModelKeepsDeleteConfirmationAfterFailure() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + deleteResult = Result.failure(IllegalStateException("database busy")) + mutableState.value = CoreState(isInitialized = true, transfers = listOf(receivedTransfer(21UL, TransferStatus.Done))) + } + val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController()) + advanceUntilIdle() + + viewModel.requestDeleteHistoryItem(21UL) + viewModel.confirmHistoryDelete() + advanceUntilIdle() + + assertEquals(ReceiveHistoryDeleteTarget.Transfer(21UL), viewModel.state.value.historyDeleteTarget) + assertFalse(viewModel.state.value.isDeletingHistory) + } + + @Test + fun receiveViewModelCompletesSuccessfulReceiveAndResetsAcquisition() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + mutableState.value = mutableState.value.copy(isInitialized = true) + inspectionResult = Result.success( + com.vnidrop.app.core.TicketInspectionModel( + kind = "vnidrop", + blobTicket = "blob", + metadata = com.vnidrop.app.core.TransferMetadataModel(1UL, "Photo", null, "hash", 1UL, 42UL), + ), + ) + } + val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController()) + advanceUntilIdle() + viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.InvitationFile, Result.success("ticket-abc")) + advanceUntilIdle() + + viewModel.receive() + advanceUntilIdle() + + assertEquals(1, core.receiveCount) + assertEquals("ticket-abc", core.lastReceiveTicket) + assertEquals("Receiver", core.lastReceiveReceiverName) + assertFalse(viewModel.state.value.isAcquisitionOpen) + assertEquals("", viewModel.state.value.ticket) + assertFalse(viewModel.state.value.isReceiving) + } + + @Test + fun receiveViewModelKeepsReviewStateWhenReceiveFails() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + mutableState.value = mutableState.value.copy(isInitialized = true) + inspectionResult = Result.success( + com.vnidrop.app.core.TicketInspectionModel( + kind = "vnidrop", + blobTicket = "blob", + metadata = com.vnidrop.app.core.TransferMetadataModel(1UL, "Photo", null, "hash", 1UL, 42UL), + ), + ) + receiveResult = Result.failure(IllegalStateException("sender refused")) + } + val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController()) + advanceUntilIdle() + viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.QrCode, Result.success("ticket-xyz")) + advanceUntilIdle() + + viewModel.receive() + advanceUntilIdle() + + assertEquals(1, core.receiveCount) + assertTrue(viewModel.state.value.isAcquisitionOpen) + assertEquals("ticket-xyz", viewModel.state.value.ticket) + assertFalse(viewModel.state.value.isReceiving) + assertTrue(viewModel.state.value.inspection != null) + } + + @Test + fun receiveViewModelClearsTicketWhenInspectionFailsButKeepsAcquisitionOpen() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + mutableState.value = mutableState.value.copy(isInitialized = true) + inspectionResult = Result.failure(IllegalArgumentException("invalid ticket")) + } + val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController()) + advanceUntilIdle() + + viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.InvitationFile, Result.success("bad-ticket")) + advanceUntilIdle() + + assertTrue(viewModel.state.value.isAcquisitionOpen) + assertEquals("", viewModel.state.value.ticket) + assertEquals(null, viewModel.state.value.inspection) + assertFalse(viewModel.state.value.isInspecting) + } + + @Test + fun receiveViewModelIgnoresDeleteForActiveReceive() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + mutableState.value = CoreState( + isInitialized = true, + transfers = listOf(receivedTransfer(21UL, TransferStatus.Receiving)), + ) + } + val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController()) + advanceUntilIdle() + + viewModel.requestDeleteHistoryItem(21UL) + assertEquals(null, viewModel.state.value.historyDeleteTarget) + } + + @Test + fun receiveViewModelDismissResetsIdleAcquisitionButNotWhileReceiving() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + mutableState.value = mutableState.value.copy(isInitialized = true) + inspectionResult = Result.success( + com.vnidrop.app.core.TicketInspectionModel( + kind = "vnidrop", + blobTicket = "blob", + metadata = com.vnidrop.app.core.TransferMetadataModel(1UL, "Photo", null, "hash", 1UL, 42UL), + ), + ) + // Keep receive suspended so dismiss can be asserted mid-transfer. + receiveResult = Result.success(Unit) + receiveSuspend = true + } + val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController()) + advanceUntilIdle() + + viewModel.openAcquisition() + viewModel.dismissAcquisition() + assertFalse(viewModel.state.value.isAcquisitionOpen) + + viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.InvitationFile, Result.success("ticket")) + advanceUntilIdle() + viewModel.receive() + // Start receive but do not finish the suspended core call yet. + testScheduler.runCurrent() + assertTrue(viewModel.state.value.isReceiving) + viewModel.dismissAcquisition() + assertTrue(viewModel.state.value.isAcquisitionOpen) + assertEquals("ticket", viewModel.state.value.ticket) + + core.completeSuspendedReceive() + advanceUntilIdle() + assertFalse(viewModel.state.value.isAcquisitionOpen) + } + private fun preferences() = FakePreferencesRepository( AppPreferences("Receiver", folder, ThemeMode.System, notificationsEnabled = false), ) private fun environment() = PlatformEnvironment("Test", "1.0", "/tmp/vnidrop") + private fun receivedTransfer(id: ULong, status: TransferStatus) = Transfer( + localId = "receive-$id", + transferId = id, + direction = TransferDirection.Receive, + status = status, + peerId = null, + transferName = "Received $id", + contentHash = "hash-$id", + fileCount = 1UL, + totalSize = 42UL, + ticket = null, + accessPolicy = ShareAccessPolicy.RequireApproval, + createdAt = 1L, + updatedAt = 1L, + ) + private companion object { val folder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "Downloads") } diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationControllerTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationControllerTest.kt new file mode 100644 index 0000000..41354d8 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationControllerTest.kt @@ -0,0 +1,53 @@ +package com.vnidrop.app.feature.receive + +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class ExternalInvitationControllerTest { + @Test + fun buffersColdLaunchInvitationsAndDeliversThemInOrder() = runTest { + val controller = ExternalInvitationController() + controller.openInvitation("first") + controller.openInvitation("second") + + val received = async { controller.invitations.take(2).toList() }.await() + + assertEquals(listOf("first", "second"), received.map { it.getOrThrow() }) + } + + @Test + fun rejectsEmptyAndOversizedDocumentsBeforeInspection() = runTest { + val controller = ExternalInvitationController() + controller.openInvitation(" ") + controller.openInvitation("x".repeat(MaxVniDropInvitationBytes + 1)) + + val received = async { controller.invitations.take(2).toList() }.await() + + assertTrue(received.all { it.isFailure }) + } + + @Test + fun decodeInvitationBytesAcceptsValidUtf8WithinLimit() { + val ticket = "vnd1:example-ticket" + assertEquals(ticket, decodeInvitationBytes(ticket.encodeToByteArray())) + } + + @Test + fun decodeInvitationBytesRejectsBinaryAndOversizePayloads() { + assertFailsWith { + decodeInvitationBytes(byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0xFD.toByte())) + } + assertFailsWith { + decodeInvitationBytes(ByteArray(MaxVniDropInvitationBytes + 1) { 'a'.code.toByte() }) + } + assertFailsWith { + decodeInvitationBytes(byteArrayOf()) + } + } +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt index e8d8c74..50bd2c0 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -21,6 +21,7 @@ import com.vnidrop.app.preferences.AppPreferences import com.vnidrop.app.preferences.PreferencesRepository import com.vnidrop.app.feature.send.FilePreviewRepository import com.vnidrop.app.ui.theme.ThemeMode +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -36,10 +37,30 @@ class FakeCoreGateway : CoreGateway { var responseResult: Result = Result.success(Unit) val responses = mutableListOf>() var shareResult: Result = Result.failure(UnsupportedOperationException()) + var inspectionResult: Result = Result.failure(UnsupportedOperationException()) + var receiveResult: Result = Result.success(Unit) + var receiveSuspend: Boolean = false + private var receiveGate: CompletableDeferred? = null var deleteResult: Result = Result.success(Unit) + var clearReceiveHistoryResult: Result = Result.success(0UL) val deletedTransfers = mutableListOf() + var clearReceiveHistoryCount = 0 + var receiveCount = 0 + var lastReceiveTicket: String? = null + var lastReceiveReceiverName: String? = null var lastShareAccessPolicy: ShareAccessPolicy? = null + fun completeSuspendedReceive() { + receiveGate?.complete(Unit) + } + + private suspend fun awaitReceiveIfNeeded() { + if (!receiveSuspend) return + val gate = CompletableDeferred() + receiveGate = gate + gate.await() + } + override suspend fun initialize(appDataDir: String): Result { mutableState.value = mutableState.value.copy(isInitialized = true) return Result.success(Unit) @@ -84,10 +105,28 @@ class FakeCoreGateway : CoreGateway { senderName: String, accessPolicy: ShareAccessPolicy, ) = Result.failure(UnsupportedOperationException()) - override suspend fun inspectTicket(ticket: String) = Result.failure(UnsupportedOperationException()) - override suspend fun receive(ticket: String, outputDir: String, receiverName: String) = Result.success(Unit) - override suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = Result.success(Unit) - override suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) = Result.success(Unit) + override suspend fun inspectTicket(ticket: String) = inspectionResult + override suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result { + receiveCount += 1 + lastReceiveTicket = ticket + lastReceiveReceiverName = receiverName + awaitReceiveIfNeeded() + return receiveResult + } + override suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result { + receiveCount += 1 + lastReceiveTicket = ticket + lastReceiveReceiverName = receiverName + awaitReceiveIfNeeded() + return receiveResult + } + override suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result { + receiveCount += 1 + lastReceiveTicket = ticket + lastReceiveReceiverName = receiverName + awaitReceiveIfNeeded() + return receiveResult + } override suspend fun cancel(transferId: ULong) = Result.success(Unit) override suspend fun delete(transferId: ULong): Result { if (deleteResult.isSuccess) { @@ -98,6 +137,21 @@ class FakeCoreGateway : CoreGateway { } return deleteResult } + override suspend fun clearReceiveHistory(): Result { + clearReceiveHistoryCount += 1 + clearReceiveHistoryResult.onSuccess { + mutableState.value = mutableState.value.copy( + transfers = mutableState.value.transfers.filterNot { transfer -> + transfer.direction == TransferDirection.Receive && transfer.status in setOf( + TransferStatus.Done, + TransferStatus.Failed, + TransferStatus.Cancelled, + ) + }, + ) + } + return clearReceiveHistoryResult + } override suspend fun receiverRequests(transferId: ULong) = Result.success(requests[transferId].orEmpty()) override suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String?): Result { responses += Triple(requestId, accepted, reason) 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 3cefe44..aefe5a1 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 @@ -60,15 +60,14 @@ class AppUiModelsTest { fun receiveStateExposesInspectAndReceiveEligibility() { val ready = ReceiveState( ticket = "ticket", - outputDirectory = "/tmp/out", + inspection = com.vnidrop.app.core.TicketInspectionModel("vnidrop", "blob", null), folderAccessStatus = com.vnidrop.app.core.FolderAccessStatus.Writable, ) - assertTrue(ready.canInspect(coreInitialized = true)) assertTrue(ready.canReceive(coreInitialized = true)) - assertFalse(ready.canInspect(coreInitialized = false)) + assertFalse(ready.canReceive(coreInitialized = false)) assertFalse(ready.copy(ticket = "").canReceive(coreInitialized = true)) - assertFalse(ready.copy(outputDirectory = "").canReceive(coreInitialized = true)) + assertFalse(ready.copy(inspection = null).canReceive(coreInitialized = true)) assertFalse(ready.copy(isReceiving = true).canReceive(coreInitialized = true)) } diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/MainViewController.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/MainViewController.kt index 3a04e5e..830f382 100644 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/MainViewController.kt +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/MainViewController.kt @@ -1,5 +1,7 @@ package com.vnidrop.app import androidx.compose.ui.window.ComposeUIViewController +import com.vnidrop.app.feature.receive.ExternalInvitationController -fun MainViewController() = ComposeUIViewController { App(rememberIosAppDependencies()) } +fun MainViewController(externalInvitations: ExternalInvitationController) = + ComposeUIViewController { App(rememberIosAppDependencies(externalInvitations)) } diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/Platform.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/Platform.ios.kt index 2178715..0f592ff 100644 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/Platform.ios.kt +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/Platform.ios.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.vnidrop.app.core.rememberFileSystemService import com.vnidrop.app.notifications.IosLocalNotificationService +import com.vnidrop.app.feature.receive.ExternalInvitationController import platform.Foundation.NSBundle import platform.Foundation.NSApplicationSupportDirectory import platform.Foundation.NSSearchPathForDirectoriesInDomains @@ -11,7 +12,7 @@ import platform.Foundation.NSUserDomainMask import platform.UIKit.UIDevice @Composable -fun rememberIosAppDependencies(): AppDependencies { +fun rememberIosAppDependencies(externalInvitations: ExternalInvitationController): AppDependencies { val fileSystemService = rememberFileSystemService() return remember(fileSystemService) { val device = UIDevice.currentDevice @@ -25,6 +26,7 @@ fun rememberIosAppDependencies(): AppDependencies { deviceInfoProvider = IosDeviceInfoProvider(device), fileSystemService = fileSystemService, localNotificationService = IosLocalNotificationService(), + externalInvitations = externalInvitations, ) } } diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt index 4a1e901..82c956f 100644 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt @@ -37,7 +37,8 @@ private class IosFileSystemService : FileSystemService { } } ReceiveFolderKind.IosSecurityScopedUrl -> validateSecurityScopedUrl(folder.value) - ReceiveFolderKind.AndroidTreeUri -> FolderAccessStatus.Unavailable + ReceiveFolderKind.AndroidTreeUri, + ReceiveFolderKind.AndroidPublicDownloads -> FolderAccessStatus.Unavailable } override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt new file mode 100644 index 0000000..31fcf75 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt @@ -0,0 +1,68 @@ +package com.vnidrop.app.feature.receive + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.readBytes +import platform.Foundation.NSFileManager +import platform.Foundation.NSURL +import platform.UIKit.UIApplication +import platform.UIKit.UIDocumentPickerDelegateProtocol +import platform.UIKit.UIDocumentPickerViewController +import platform.UIKit.UIModalPresentationFormSheet +import platform.UniformTypeIdentifiers.UTTypeData +import platform.darwin.NSObject + +private var retainedInvitationDelegate: InvitationDocumentDelegate? = null + +@Composable +actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = remember { + object : ReceiveInvitationActions { + override val fileAvailability = ReceiveMethodAvailability.Available + override val qrAvailability = ReceiveMethodAvailability.Unavailable + override val nfcAvailability = ReceiveMethodAvailability.Unavailable + + @OptIn(ExperimentalForeignApi::class) + override fun pickInvitation(onResult: (Result) -> Unit) { + val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController + ?: return onResult(Result.failure(IllegalStateException("Could not find an iOS view controller"))) + val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeData), asCopy = true) + val delegate = InvitationDocumentDelegate(onResult) + retainedInvitationDelegate = delegate + picker.delegate = delegate + picker.modalPresentationStyle = UIModalPresentationFormSheet + presenter.presentViewController(picker, animated = true, completion = null) + } + + override fun scanQrCode(onResult: (Result) -> Unit) = + onResult(Result.failure(UnsupportedOperationException("QR scanning is not enabled for this iOS build"))) + + override fun readNfcInvitation(onResult: (Result) -> Unit) = + onResult(Result.failure(UnsupportedOperationException("NFC reading is not enabled for this iOS build"))) + + override fun cancel() = Unit + } +} + +private class InvitationDocumentDelegate( + private val onResult: (Result) -> Unit, +) : NSObject(), UIDocumentPickerDelegateProtocol { + @OptIn(ExperimentalForeignApi::class) + override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) { + val url = didPickDocumentsAtURLs.firstOrNull() as? NSURL + onResult(runCatching { + requireNotNull(url) { "The selected invitation URL was invalid" } + val path = url.path ?: error("The invitation path was invalid") + val data = NSFileManager.defaultManager.contentsAtPath(path) ?: error("The invitation could not be opened") + val length = data.length.toInt() + require(length <= MaxInvitationBytes) { "The invitation is too large" } + val bytes = data.bytes?.readBytes(length) ?: error("The invitation is empty") + decodeInvitationBytes(bytes) + }) + retainedInvitationDelegate = null + } + + override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) { + retainedInvitationDelegate = null + } +} diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt index cef6d80..d6a8451 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt @@ -4,10 +4,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.vnidrop.app.core.rememberFileSystemService import com.vnidrop.app.notifications.JvmLocalNotificationService +import com.vnidrop.app.feature.receive.ExternalInvitationController import java.net.NetworkInterface @Composable -fun rememberJvmAppDependencies(): AppDependencies { +fun rememberJvmAppDependencies(externalInvitations: ExternalInvitationController): AppDependencies { val fileSystemService = rememberFileSystemService() return remember(fileSystemService) { AppDependencies( @@ -20,6 +21,7 @@ fun rememberJvmAppDependencies(): AppDependencies { deviceInfoProvider = JvmDeviceInfoProvider, fileSystemService = fileSystemService, localNotificationService = JvmLocalNotificationService(), + externalInvitations = externalInvitations, ) } } diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.jvm.kt new file mode 100644 index 0000000..8af4f4a --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.jvm.kt @@ -0,0 +1,50 @@ +package com.vnidrop.app.feature.receive + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import java.awt.EventQueue +import java.awt.FileDialog +import java.awt.Frame +import java.awt.KeyboardFocusManager +import java.io.File + +@Composable +actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = remember { + object : ReceiveInvitationActions { + override val fileAvailability = ReceiveMethodAvailability.Available + override val qrAvailability = ReceiveMethodAvailability.Hidden + override val nfcAvailability = ReceiveMethodAvailability.Hidden + + override fun pickInvitation(onResult: (Result) -> Unit) { + EventQueue.invokeLater { + val dialog = FileDialog(activeFrame(), "Open VniDrop invitation", FileDialog.LOAD).apply { + setFilenameFilter { _, name -> name.endsWith(".$VniDropInvitationExtension", ignoreCase = true) } + } + try { + dialog.isVisible = true + val directory = dialog.directory + val name = dialog.file + if (directory != null && name != null) onResult(readInvitation(File(directory, name))) + } finally { dialog.dispose() } + } + } + + override fun scanQrCode(onResult: (Result) -> Unit) = + onResult(Result.failure(UnsupportedOperationException("QR scanning is unavailable on desktop"))) + + override fun readNfcInvitation(onResult: (Result) -> Unit) = + onResult(Result.failure(UnsupportedOperationException("NFC is unavailable on desktop"))) + + override fun cancel() = Unit + } +} + +private fun readInvitation(file: File): Result = runCatching { + require(file.extension.equals(VniDropInvitationExtension, ignoreCase = true)) { "This is not a VniDrop invitation" } + val bytes = file.inputStream().use { it.readNBytes(MaxInvitationBytes + 1) } + decodeInvitationBytes(bytes) +} + +private fun activeFrame(): Frame? = + (KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow as? Frame) + ?: Frame.getFrames().firstOrNull { it.isActive || it.isFocused } diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt index 87fa8d3..b8693d0 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt @@ -39,13 +39,46 @@ class AppPreferencesRepositoryTest { assertEquals(true, repository.preferences.first().notificationsEnabled) } - private fun repositoryForTest(): AppPreferencesRepository { + @Test + fun legacyAndroidAppDownloadsPathIsPromotedToDefault() = runBlocking { + val publicDefault = ReceiveFolder( + kind = ReceiveFolderKind.AndroidPublicDownloads, + value = "media-store:downloads", + displayName = "Downloads", + ) + val repository = repositoryForTest(default = publicDefault) + repository.setReceiveFolder( + ReceiveFolder( + kind = ReceiveFolderKind.FileSystemPath, + value = "/storage/emulated/0/Android/data/com.vnidrop.app/files/Downloads", + displayName = "App downloads", + ), + ) + + assertEquals(publicDefault, repository.preferences.first().receiveFolder) + } + + @Test + fun customFileSystemFolderIsNotPromotedAway() = runBlocking { + val repository = repositoryForTest() + val custom = ReceiveFolder( + kind = ReceiveFolderKind.FileSystemPath, + value = "/tmp/custom-receive", + displayName = "Custom", + ) + repository.setReceiveFolder(custom) + assertEquals(custom, repository.preferences.first().receiveFolder) + } + + private fun repositoryForTest( + default: ReceiveFolder = defaultFolder, + ): AppPreferencesRepository { val directory = Files.createTempDirectory("vnidrop-preferences-test").toString() return AppPreferencesRepository( dataStore = createAppPreferencesDataStore(directory), defaults = AppPreferencesDefaults( username = "Device Name", - receiveFolder = defaultFolder, + receiveFolder = default, themeMode = ThemeMode.System, ), ) diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt index 58d1ea8..0377e9f 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -24,6 +24,11 @@ import androidx.compose.runtime.mutableStateOf import com.vnidrop.app.feature.approvals.ApprovalModalHost import com.vnidrop.app.feature.approvals.ApprovalState import com.vnidrop.app.feature.approvals.PendingApproval +import com.vnidrop.app.feature.receive.ReceiveHistoryDeleteTarget +import com.vnidrop.app.feature.receive.ReceiveInvitationActions +import com.vnidrop.app.feature.receive.ReceiveMethodAvailability +import com.vnidrop.app.feature.receive.ReceiveScreen +import com.vnidrop.app.feature.receive.ReceiveState import com.vnidrop.app.feature.settings.SettingsScreen import com.vnidrop.app.feature.settings.SettingsSection import com.vnidrop.app.feature.settings.SettingsState @@ -316,6 +321,85 @@ class FoundationComposeTest { onNodeWithContentDescription("Close").assertIsDisplayed() } + @Test + fun phoneReceiveEmptyStateOpensAcquisitionMethods() = runComposeUiTest { + val state = mutableStateOf(ReceiveState()) + val actions = object : ReceiveInvitationActions { + override val fileAvailability = ReceiveMethodAvailability.Available + override val qrAvailability = ReceiveMethodAvailability.Hidden + override val nfcAvailability = ReceiveMethodAvailability.Hidden + override fun pickInvitation(onResult: (Result) -> Unit) = Unit + override fun scanQrCode(onResult: (Result) -> Unit) = Unit + override fun readNfcInvitation(onResult: (Result) -> Unit) = Unit + override fun cancel() = Unit + } + setContent { + VniDropTheme(isDarkTheme = false) { + ReceiveScreen( + coreState = CoreState(isInitialized = true), + state = state.value, + windowClass = WindowClass.Phone, + actions = actions, + onOpenAcquisition = { state.value = state.value.copy(isAcquisitionOpen = true) }, + onDismissAcquisition = {}, + onReceiverNameChanged = {}, + onInvitationResult = { _, _ -> }, + onWaitingForNfc = {}, + onReceive = {}, + onRequestDeleteHistoryItem = {}, + onRequestClearHistory = {}, + onDismissHistoryDelete = {}, + onConfirmHistoryDelete = {}, + ) + } + } + + onNodeWithText("Receive your first file").assertIsDisplayed() + onNodeWithText("Receive files").performClick() + onNodeWithText("How would you like to connect?").assertIsDisplayed() + onNodeWithText("Open a .vnd invitation").assertIsDisplayed() + } + + @Test + fun receiveHistoryOffersPerItemDeleteAndConfirmedClearAll() = runComposeUiTest { + val state = mutableStateOf(ReceiveState()) + val actions = object : ReceiveInvitationActions { + override val fileAvailability = ReceiveMethodAvailability.Available + override val qrAvailability = ReceiveMethodAvailability.Hidden + override val nfcAvailability = ReceiveMethodAvailability.Hidden + override fun pickInvitation(onResult: (Result) -> Unit) = Unit + override fun scanQrCode(onResult: (Result) -> Unit) = Unit + override fun readNfcInvitation(onResult: (Result) -> Unit) = Unit + override fun cancel() = Unit + } + setContent { + VniDropTheme(isDarkTheme = false) { + ReceiveScreen( + coreState = CoreState(isInitialized = true, transfers = listOf(receivedTransfer())), + state = state.value, + windowClass = WindowClass.Phone, + actions = actions, + onOpenAcquisition = {}, + onDismissAcquisition = {}, + onReceiverNameChanged = {}, + onInvitationResult = { _, _ -> }, + onWaitingForNfc = {}, + onReceive = {}, + onRequestDeleteHistoryItem = { state.value = state.value.copy(historyDeleteTarget = ReceiveHistoryDeleteTarget.Transfer(it)) }, + onRequestClearHistory = { state.value = state.value.copy(historyDeleteTarget = ReceiveHistoryDeleteTarget.All) }, + onDismissHistoryDelete = { state.value = state.value.copy(historyDeleteTarget = null) }, + onConfirmHistoryDelete = {}, + ) + } + } + + onNodeWithContentDescription("Delete from receive history").assertIsDisplayed() + onNodeWithText("Clear history").performClick() + onNodeWithText("Clear receive history?").assertIsDisplayed() + onNodeWithText("Downloaded files will remain on this device.", substring = true).assertIsDisplayed() + onNodeWithContentDescription("Close").assertIsDisplayed() + } + @Test fun snackbarActionAndCancellationAreForwarded() = runComposeUiTest { val controller = UiMessageController() @@ -361,4 +445,20 @@ class FoundationComposeTest { createdAt = 1L, updatedAt = 1L, ) + + private fun receivedTransfer() = Transfer( + localId = "receive-10", + transferId = 10UL, + direction = TransferDirection.Receive, + status = TransferStatus.Done, + peerId = "sender", + transferName = "Holiday photos", + contentHash = "received-hash", + fileCount = 3UL, + totalSize = 4096UL, + ticket = null, + accessPolicy = ShareAccessPolicy.RequireApproval, + createdAt = 1L, + updatedAt = 2L, + ) }