feat(receive): add adaptive receive flow and document opening

This commit is contained in:
2026-07-12 04:15:21 +02:00
parent 757966379b
commit 996cc0a5ab
30 changed files with 1203 additions and 92 deletions

View File

@@ -14,14 +14,21 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.NoActionBar">
<meta-data android:name="com.google.mlkit.vision.DEPENDENCIES" android:value="barcode_ui"/>
<activity
android:exported="true"
android:launchMode="singleTask"
android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="application/vnd.vnidrop.transfer"/>
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"

View File

@@ -1,16 +1,67 @@
package com.vnidrop.app
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.lifecycle.lifecycleScope
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.VniDropInvitationMimeType
import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : ComponentActivity() {
private val externalInvitations = ExternalInvitationController()
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
App(rememberAndroidAppDependencies(this))
App(rememberAndroidAppDependencies(this, externalInvitations))
}
handleInvitationIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleInvitationIntent(intent)
}
private fun handleInvitationIntent(source: Intent?) {
if (source?.action != Intent.ACTION_VIEW) return
val uri = source.data
setIntent(Intent(this, MainActivity::class.java).setAction(Intent.ACTION_MAIN))
if (uri == null) {
externalInvitations.reportOpenFailure("The invitation could not be opened")
return
}
lifecycleScope.launch {
val result = withContext(Dispatchers.IO) { readInvitation(uri, source.type) }
result.fold(externalInvitations::openInvitation) { error ->
externalInvitations.reportOpenFailure(error.message ?: "The invitation could not be opened")
}
}
}
private fun readInvitation(uri: Uri, declaredType: String?): Result<String> = 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()
}
}

View File

@@ -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",
)
}
}
}

View File

@@ -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<String>) {
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

View File

@@ -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" }

View File

@@ -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"
}
}
}

View File

@@ -4,6 +4,45 @@
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>VniDrop Invitation</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>com.vnidrop.app.invitation</string>
</array>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeDescription</key>
<string>VniDrop Invitation</string>
<key>UTTypeIdentifier</key>
<string>com.vnidrop.app.invitation</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>vnd</string>
</array>
<key>public.mime-type</key>
<string>application/vnd.vnidrop.transfer</string>
</dict>
</dict>
</array>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
</dict>

View File

@@ -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)
}
}
}

View File

@@ -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 {

View File

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

View File

@@ -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<String>) -> 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<String>) -> Unit) {
fileResult = onResult
filePicker.launch(arrayOf(InvitationMimeType, "application/octet-stream", "text/plain"))
}
override fun scanQrCode(onResult: (Result<String>) -> 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<String>) -> 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) }
}
}
}
}

View File

@@ -79,6 +79,30 @@
<string name="button_refuse">Refuse</string>
<string name="button_approve">Approve</string>
<string name="receive_title">Receive</string>
<string name="receive_new_subtitle">Files received directly on this device.</string>
<string name="receive_empty_title">Receive your first file</string>
<string name="receive_empty_body">Open a VniDrop invitation, scan its QR code, or read a nearby NFC tag.</string>
<string name="button_receive_files">Receive files</string>
<string name="receive_history_title">Received files</string>
<string name="receive_clear_history">Clear history</string>
<string name="receive_delete_history_item">Delete from receive history</string>
<string name="receive_delete_history_title">Remove from history?</string>
<string name="receive_delete_history_description">“%1$s” will be removed from VniDrops history. The downloaded file will remain on this device.</string>
<string name="receive_clear_history_title">Clear receive history?</string>
<string name="receive_clear_history_description">All completed, failed, and cancelled receives will be removed from VniDrops history. Downloaded files will remain on this device.</string>
<string name="receive_history_cleared">Receive history cleared.</string>
<string name="receive_choose_method_title">How would you like to connect?</string>
<string name="receive_choose_method_body">Choose the invitation method available to you.</string>
<string name="receive_method_file">Open a .vnd invitation</string>
<string name="receive_method_file_description">Choose an invitation saved or shared to this device.</string>
<string name="receive_method_scan">Scan QR code</string>
<string name="receive_method_scan_description">Use the camera to scan the senders VniDrop code.</string>
<string name="receive_method_nfc">Read NFC tag</string>
<string name="receive_method_nfc_description">Hold this device near the senders invitation tag.</string>
<string name="receive_nfc_waiting">Hold near the NFC tag…</string>
<string name="receive_review_title">Review transfer</string>
<string name="receive_unknown_transfer">VniDrop transfer</string>
<string name="receive_completed">Transfer received.</string>
<string name="receive_subtitle">Inspect a ticket, request access, and stream files into the output directory.</string>
<string name="ticket_card_title">Ticket</string>
<string name="field_ticket">Ticket</string>

View File

@@ -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) }
}
}

View File

@@ -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,
)

View File

@@ -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<Result<String>>(capacity = 16)
val invitations: Flow<Result<String>> = 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<String> = runCatching {
require(raw.isNotBlank()) { "The invitation is empty" }
require(raw.encodeToByteArray().size <= MaxVniDropInvitationBytes) { "The invitation is too large" }
raw
}

View File

@@ -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<String>) -> Unit)
fun scanQrCode(onResult: (Result<String>) -> Unit)
fun readNfcInvitation(onResult: (Result<String>) -> Unit)
fun cancel()
}
@Composable
expect fun rememberReceiveInvitationActions(): ReceiveInvitationActions
internal const val InvitationMimeType = VniDropInvitationMimeType
internal const val MaxInvitationBytes = MaxVniDropInvitationBytes

View File

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

View File

@@ -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<String>) -> 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,
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,
)
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),
)
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<String>) -> 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()

View File

@@ -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<String>) {
_state.update { it.copy(isWaitingForNfc = false) }
result.fold(
onSuccess = { raw -> inspectInvitation(method, raw) },
onFailure = messages::error,
)
}
fun receive() {
@@ -71,7 +134,6 @@ 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)
@@ -80,12 +142,56 @@ class ReceiveViewModel(
folder.value,
current.receiverName,
)
else -> repository.receive(current.ticket, current.outputDirectory, current.receiverName)
else -> repository.receive(current.ticket, folder.value, current.receiverName)
}
result.onFailure(messages::error)
} finally {
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

View File

@@ -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(

View File

@@ -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")
}

View File

@@ -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 })
}
}

View File

@@ -36,8 +36,11 @@ class FakeCoreGateway : CoreGateway {
var responseResult: Result<Unit> = Result.success(Unit)
val responses = mutableListOf<Triple<String, Boolean, String?>>()
var shareResult: Result<Share> = Result.failure(UnsupportedOperationException())
var inspectionResult: Result<TicketInspectionModel> = Result.failure(UnsupportedOperationException())
var deleteResult: Result<Unit> = Result.success(Unit)
var clearReceiveHistoryResult: Result<ULong> = Result.success(0UL)
val deletedTransfers = mutableListOf<ULong>()
var clearReceiveHistoryCount = 0
var lastShareAccessPolicy: ShareAccessPolicy? = null
override suspend fun initialize(appDataDir: String): Result<Unit> {
@@ -84,7 +87,7 @@ class FakeCoreGateway : CoreGateway {
senderName: String,
accessPolicy: ShareAccessPolicy,
) = Result.failure<Share>(UnsupportedOperationException())
override suspend fun inspectTicket(ticket: String) = Result.failure<TicketInspectionModel>(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<ULong> {
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<Unit> {
responses += Triple(requestId, accepted, reason)

View File

@@ -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))
}

View File

@@ -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)) }

View File

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

View File

@@ -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<String>) -> 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<String>) -> Unit) =
onResult(Result.failure(UnsupportedOperationException("QR scanning is not enabled for this iOS build")))
override fun readNfcInvitation(onResult: (Result<String>) -> 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<String>) -> 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
}
}

View File

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

View File

@@ -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<String>) -> 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<String>) -> Unit) =
onResult(Result.failure(UnsupportedOperationException("QR scanning is unavailable on desktop")))
override fun readNfcInvitation(onResult: (Result<String>) -> Unit) =
onResult(Result.failure(UnsupportedOperationException("NFC is unavailable on desktop")))
override fun cancel() = Unit
}
}
private fun readInvitation(file: File): Result<String> = 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 }

View File

@@ -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<String>) -> Unit) = Unit
override fun scanQrCode(onResult: (Result<String>) -> Unit) = Unit
override fun readNfcInvitation(onResult: (Result<String>) -> 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,
)
}