From 757966379b73278d8c86c7eed1cad5d88ee5aa0d Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Sun, 12 Jul 2026 04:15:11 +0200 Subject: [PATCH 1/5] feat(core): add atomic receive history cleanup --- crates/vnidrop/src/repository.rs | 64 ++++++++ crates/vnidrop/src/runtime.rs | 17 +++ crates/vnidrop/src/tests/repository.rs | 143 ++++++++++++++++++ .../kotlin/com/vnidrop/app/core/CoreModels.kt | 1 + .../com/vnidrop/app/core/CoreRepository.kt | 6 + 5 files changed, 231 insertions(+) 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/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/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) } From 996cc0a5ab9e0f1c2a8e649bb0525f024e11156e Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Sun, 12 Jul 2026 04:15:21 +0200 Subject: [PATCH 2/5] feat(receive): add adaptive receive flow and document opening --- androidApp/src/main/AndroidManifest.xml | 7 + .../kotlin/com/vnidrop/app/MainActivity.kt | 53 ++- desktopApp/build.gradle.kts | 5 + .../src/main/kotlin/com/vnidrop/app/main.kt | 40 ++- gradle/libs.versions.toml | 2 + iosApp/iosApp/ContentView.swift | 56 ++- iosApp/iosApp/Info.plist | 39 ++ iosApp/iosApp/iOSApp.swift | 5 +- shared/build.gradle.kts | 10 + .../com/vnidrop/app/Platform.android.kt | 4 +- .../ReceiveInvitationActions.android.kt | 86 +++++ .../composeResources/values/strings.xml | 24 ++ .../commonMain/kotlin/com/vnidrop/app/App.kt | 32 +- .../kotlin/com/vnidrop/app/Platform.kt | 2 + .../receive/ExternalInvitationController.kt | 36 ++ .../receive/ReceiveInvitationActions.kt | 24 ++ .../app/feature/receive/ReceiveRoute.kt | 22 +- .../app/feature/receive/ReceiveScreen.kt | 339 +++++++++++++++--- .../app/feature/receive/ReceiveViewModel.kt | 156 ++++++-- .../com/vnidrop/app/ui/components/Buttons.kt | 12 + .../com/vnidrop/app/feature/ViewModelsTest.kt | 93 ++++- .../ExternalInvitationControllerTest.kt | 33 ++ .../kotlin/com/vnidrop/app/support/Fakes.kt | 20 +- .../vnidrop/app/ui/state/AppUiModelsTest.kt | 7 +- .../com/vnidrop/app/MainViewController.kt | 4 +- .../kotlin/com/vnidrop/app/Platform.ios.kt | 4 +- .../receive/ReceiveInvitationActions.ios.kt | 66 ++++ .../kotlin/com/vnidrop/app/Platform.jvm.kt | 4 +- .../receive/ReceiveInvitationActions.jvm.kt | 49 +++ .../vnidrop/app/ui/FoundationComposeTest.kt | 61 ++++ 30 files changed, 1203 insertions(+), 92 deletions(-) create mode 100644 shared/src/androidMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.android.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationController.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.kt create mode 100644 shared/src/commonTest/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationControllerTest.kt create mode 100644 shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt create mode 100644 shared/src/jvmMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.jvm.kt diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 2f9818c..4f57d20 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -14,14 +14,21 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@android:style/Theme.Material.NoActionBar"> + + + + + + + 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 hasExpectedName = uri.lastPathSegment?.endsWith(".$VniDropInvitationExtension", ignoreCase = true) == 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") + require(bytes.size <= MaxVniDropInvitationBytes) { "The invitation is too large" } + Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString() + } } 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..7c07526 100644 --- a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt +++ b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt @@ -4,9 +4,22 @@ 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 java.awt.Desktop +import java.io.File +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction -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 +29,34 @@ 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) } + require(bytes.size <= MaxVniDropInvitationBytes) { "The invitation is too large" } + Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString() + } + 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/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/feature/receive/ReceiveInvitationActions.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.android.kt new file mode 100644 index 0000000..4665492 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.android.kt @@ -0,0 +1,86 @@ +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") + require(bytes.size <= MaxInvitationBytes) { "The invitation is too large" } + bytes.decodeToString() + }) + } + 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) { + 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"))) + 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)) } + } + + 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"))) + 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") + record.payload.decodeToString() + } 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..8cd9f91 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,8 @@ 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 @Composable fun App(dependencies: AppDependencies) { @@ -69,8 +74,22 @@ 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) { + receiveViewModel.coreState.filter { it.isInitialized }.first() + 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 +116,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 +135,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/feature/receive/ExternalInvitationController.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationController.kt new file mode 100644 index 0000000..8aa4dcb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationController.kt @@ -0,0 +1,36 @@ +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 +} 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/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..455ed66 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,105 @@ 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) + } + 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..c467b09 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationControllerTest.kt @@ -0,0 +1,33 @@ +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.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 }) + } +} 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..fa0576d 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -36,8 +36,11 @@ 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 deleteResult: Result = Result.success(Unit) + var clearReceiveHistoryResult: Result = Result.success(0UL) val deletedTransfers = mutableListOf() + var clearReceiveHistoryCount = 0 var lastShareAccessPolicy: ShareAccessPolicy? = null override suspend fun initialize(appDataDir: String): Result { @@ -84,7 +87,7 @@ class FakeCoreGateway : CoreGateway { senderName: String, accessPolicy: ShareAccessPolicy, ) = Result.failure(UnsupportedOperationException()) - override suspend fun inspectTicket(ticket: String) = Result.failure(UnsupportedOperationException()) + override suspend fun inspectTicket(ticket: String) = inspectionResult 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) @@ -98,6 +101,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/feature/receive/ReceiveInvitationActions.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt new file mode 100644 index 0000000..90fa685 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt @@ -0,0 +1,66 @@ +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") + require(data.length.toLong() <= MaxInvitationBytes) { "The invitation is too large" } + data.bytes?.readBytes(data.length.toInt())?.decodeToString() ?: error("The invitation is empty") + }) + 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..6b67fc0 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.jvm.kt @@ -0,0 +1,49 @@ +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(".vnd", 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.length() <= MaxInvitationBytes) { "The invitation is too large" } + file.readText() +} + +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/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt index 58d1ea8..933799c 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,46 @@ class FoundationComposeTest { onNodeWithContentDescription("Close").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 +406,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, + ) } From 4050a7c0115ae59dcb410f4f7ea16964c12d4bd5 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Sun, 12 Jul 2026 04:31:19 +0200 Subject: [PATCH 3/5] fix(receive): harden invitation open path and cover receive loop Tighten cold-open and in-app invitation handling so ticket acquisition is stricter and less racey across hosts, and expand automated coverage for the receive history and acquisition flow before merge. --- androidApp/src/main/AndroidManifest.xml | 15 ++ .../kotlin/com/vnidrop/app/MainActivity.kt | 15 +- .../src/main/kotlin/com/vnidrop/app/main.kt | 10 +- .../ReceiveInvitationActions.android.kt | 14 +- .../commonMain/kotlin/com/vnidrop/app/App.kt | 16 ++- .../receive/ExternalInvitationController.kt | 16 +++ .../com/vnidrop/app/feature/ViewModelsTest.kt | 131 ++++++++++++++++++ .../ExternalInvitationControllerTest.kt | 20 +++ .../kotlin/com/vnidrop/app/support/Fakes.kt | 42 +++++- .../receive/ReceiveInvitationActions.ios.kt | 6 +- .../receive/ReceiveInvitationActions.jvm.kt | 7 +- .../vnidrop/app/ui/FoundationComposeTest.kt | 39 ++++++ 12 files changed, 301 insertions(+), 30 deletions(-) diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 4f57d20..c7faec7 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -24,11 +24,26 @@ + + + + + + + + + + + + + + + = runCatching { val resolvedType = declaredType ?: contentResolver.getType(uri) - val hasExpectedName = uri.lastPathSegment?.endsWith(".$VniDropInvitationExtension", ignoreCase = true) == true + 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") - require(bytes.size <= MaxVniDropInvitationBytes) { "The invitation is too large" } - Charsets.UTF_8.newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT) - .decode(ByteBuffer.wrap(bytes)) - .toString() + decodeInvitationBytes(bytes) } } diff --git a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt index 7c07526..0921de6 100644 --- a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt +++ b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt @@ -7,10 +7,9 @@ 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 -import java.nio.ByteBuffer -import java.nio.charset.CodingErrorAction fun main(args: Array) { val externalInvitations = ExternalInvitationController() @@ -45,12 +44,7 @@ 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) } - require(bytes.size <= MaxVniDropInvitationBytes) { "The invitation is too large" } - Charsets.UTF_8.newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT) - .decode(ByteBuffer.wrap(bytes)) - .toString() + decodeInvitationBytes(bytes) } result.fold(::openInvitation) { error -> reportOpenFailure(error.message ?: "The invitation could not be opened") 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 index 4665492..4c13dd3 100644 --- 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 @@ -26,8 +26,7 @@ actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions { callback(runCatching { val bytes = context.contentResolver.openInputStream(uri)?.use { it.readNBytes(MaxInvitationBytes + 1) } ?: error("The invitation could not be opened") - require(bytes.size <= MaxInvitationBytes) { "The invitation is too large" } - bytes.decodeToString() + decodeInvitationBytes(bytes) }) } val nfcAdapter = remember(activity) { activity?.let(NfcAdapter::getDefaultAdapter) } @@ -38,12 +37,15 @@ actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions { 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")) + 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() @@ -54,12 +56,16 @@ actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions { 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") @@ -68,7 +74,7 @@ actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions { 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") - record.payload.decodeToString() + decodeInvitationBytes(record.payload) } finally { ndef.close() } } host.runOnUiThread { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index 8cd9f91..2db6bf5 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -40,6 +40,7 @@ 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) { @@ -82,7 +83,20 @@ fun App(dependencies: AppDependencies) { dependencies.externalInvitations.invitations.collect { invitation -> appViewModel.selectDestination(AppDestination.Receive) if (invitation.isSuccess) { - receiveViewModel.coreState.filter { it.isInitialized }.first() + // 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() 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 index 8aa4dcb..5a8844a 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationController.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationController.kt @@ -34,3 +34,19 @@ internal fun validateInvitation(raw: String): Result = runCatching { 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/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index 455ed66..e926198 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -308,6 +308,137 @@ class ViewModelsTest { 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), ) 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 index c467b09..41354d8 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationControllerTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationControllerTest.kt @@ -6,6 +6,7 @@ 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 { @@ -30,4 +31,23 @@ class ExternalInvitationControllerTest { 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 fa0576d..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 @@ -37,12 +38,29 @@ class FakeCoreGateway : CoreGateway { 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) @@ -88,9 +106,27 @@ class FakeCoreGateway : CoreGateway { accessPolicy: ShareAccessPolicy, ) = Result.failure(UnsupportedOperationException()) override suspend fun inspectTicket(ticket: String) = inspectionResult - 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 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) { diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt index 90fa685..31fcf75 100644 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt @@ -54,8 +54,10 @@ private class InvitationDocumentDelegate( 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") - require(data.length.toLong() <= MaxInvitationBytes) { "The invitation is too large" } - data.bytes?.readBytes(data.length.toInt())?.decodeToString() ?: error("The invitation is empty") + 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 } 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 index 6b67fc0..8af4f4a 100644 --- 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 @@ -18,7 +18,7 @@ actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = rememb override fun pickInvitation(onResult: (Result) -> Unit) { EventQueue.invokeLater { val dialog = FileDialog(activeFrame(), "Open VniDrop invitation", FileDialog.LOAD).apply { - setFilenameFilter { _, name -> name.endsWith(".vnd", ignoreCase = true) } + setFilenameFilter { _, name -> name.endsWith(".$VniDropInvitationExtension", ignoreCase = true) } } try { dialog.isVisible = true @@ -40,8 +40,9 @@ actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = rememb } private fun readInvitation(file: File): Result = runCatching { - require(file.length() <= MaxInvitationBytes) { "The invitation is too large" } - file.readText() + 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? = 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 933799c..0377e9f 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -321,6 +321,45 @@ 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()) From d5d4e37d1cd40804e1a89c14309f96e335ce99d0 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Sun, 12 Jul 2026 04:40:10 +0200 Subject: [PATCH 4/5] fix(android): make default receive path actually writable Android emulated storage rejects hard-link commit even when canWrite() reports success. Fall back to exclusive rename for publish, probe real writes during folder validation, and label the default app downloads dir clearly so it is not confused with shared system Downloads. --- crates/vnidrop/CORE_FLOW.md | 13 +- crates/vnidrop/src/filesystem.rs | 128 +++++++++++++++++- crates/vnidrop/src/tests/filesystem.rs | 19 +++ .../app/core/FileSystemService.android.kt | 27 +++- 4 files changed, 174 insertions(+), 13 deletions(-) diff --git a/crates/vnidrop/CORE_FLOW.md b/crates/vnidrop/CORE_FLOW.md index 96cf5bd..0d24374 100644 --- a/crates/vnidrop/CORE_FLOW.md +++ b/crates/vnidrop/CORE_FLOW.md @@ -59,9 +59,16 @@ 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 app-specific external Downloads directory + (`getExternalFilesDir`), which is always writable by the process. Shared + system folders require a SAF tree URI via the folder picker; those receives + stream through `ReceiveOutputSink` instead of raw filesystem paths. - 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/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/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..5818c7a 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 @@ -22,14 +22,17 @@ private class AndroidFileSystemService( private val context: Context, ) : FileSystemService { override fun defaultReceiveFolder(): ReceiveFolder { + // App-specific external storage is always writable without SAF or + // legacy storage permissions. It is NOT the shared system Downloads + // gallery — that still requires "Choose folder" (tree URI). val path = context .getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?.absolutePath - ?: (System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop-receive") + ?: context.filesDir.resolve("Downloads").apply { mkdirs() }.absolutePath return ReceiveFolder( kind = ReceiveFolderKind.FileSystemPath, value = path, - displayName = "Downloads", + displayName = "App downloads", ) } @@ -64,11 +67,27 @@ private class AndroidFileSystemService( } } + /** + * Probe a real create/write/delete instead of [java.io.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 + if (!directory.exists() && !directory.mkdirs()) { + return FolderAccessStatus.Unavailable + } + if (!directory.isDirectory) return FolderAccessStatus.Unavailable + val probe = java.io.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 validateTreeUri(value: String): FolderAccessStatus { From a79c61b9d79247716145464c829a665cfd100021 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Sun, 12 Jul 2026 04:43:34 +0200 Subject: [PATCH 5/5] feat(android): receive into system Downloads via MediaStore Default Android receive destination is now the shared system Downloads collection (like desktop), using MediaStore on API 29+ instead of app-private storage. Legacy app-private defaults are promoted back to public Downloads so existing installs pick up the fix. --- androidApp/src/main/AndroidManifest.xml | 4 + crates/vnidrop/CORE_FLOW.md | 10 +- shared/src/androidMain/AndroidManifest.xml | 3 + .../app/core/FileSystemService.android.kt | 193 ++++++++++++++++-- .../com/vnidrop/app/core/FileSystemService.kt | 5 + .../preferences/AppPreferencesRepository.kt | 35 +++- .../vnidrop/app/core/FileSystemService.ios.kt | 3 +- .../AppPreferencesRepositoryTest.kt | 37 +++- 8 files changed, 255 insertions(+), 35 deletions(-) diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index c7faec7..f7ba8e4 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -5,6 +5,10 @@ + + + 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 5818c7a..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,31 +28,40 @@ private class AndroidFileSystemService( private val context: Context, ) : FileSystemService { override fun defaultReceiveFolder(): ReceiveFolder { - // App-specific external storage is always writable without SAF or - // legacy storage permissions. It is NOT the shared system Downloads - // gallery — that still requires "Choose folder" (tree URI). - val path = context - .getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) - ?.absolutePath - ?: context.filesDir.resolve("Downloads").apply { mkdirs() }.absolutePath - return ReceiveFolder( - kind = ReceiveFolderKind.FileSystemPath, - value = path, - displayName = "App 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, @@ -68,19 +83,19 @@ private class AndroidFileSystemService( } /** - * Probe a real create/write/delete instead of [java.io.File.canWrite]. + * 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) + val directory = File(path) if (!directory.exists() && !directory.mkdirs()) { return FolderAccessStatus.Unavailable } if (!directory.isDirectory) return FolderAccessStatus.Unavailable - val probe = java.io.File(directory, ".vnidrop-write-test-${UUID.randomUUID()}") + val probe = File(directory, ".vnidrop-write-test-${UUID.randomUUID()}") try { probe.outputStream().use { stream -> stream.write(1) } if (!probe.exists()) return FolderAccessStatus.Unavailable @@ -90,6 +105,16 @@ private class AndroidFileSystemService( } }.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 { val uri = Uri.parse(value) val hasPermission = context.contentResolver.persistedUriPermissions.any { permission -> @@ -107,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/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/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/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/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, ), )