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, + ) }