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:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@android:style/Theme.Material.NoActionBar"> android:theme="@android:style/Theme.Material.NoActionBar">
<meta-data android:name="com.google.mlkit.vision.DEPENDENCIES" android:value="barcode_ui"/>
<activity <activity
android:exported="true" android:exported="true"
android:launchMode="singleTask"
android:name=".MainActivity"> android:name=".MainActivity">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </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> </activity>
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"

View File

@@ -1,16 +1,67 @@
package com.vnidrop.app package com.vnidrop.app
import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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() { class MainActivity : ComponentActivity() {
private val externalInvitations = ExternalInvitationController()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { 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) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "com.vnidrop.app" packageName = "com.vnidrop.app"
packageVersion = "1.0.0" 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 androidx.compose.ui.window.application
import com.vnidrop.app.platform.DesktopAppearanceBridge import com.vnidrop.app.platform.DesktopAppearanceBridge
import com.vnidrop.app.feature.send.DesktopShareBridge 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() configureMacOsNativeAppearance()
configureInvitationOpenHandler(externalInvitations)
args.asSequence()
.map(::File)
.filter { it.extension.equals(VniDropInvitationExtension, ignoreCase = true) }
.forEach { externalInvitations.openFile(it) }
DesktopAppearanceBridge.applyNativeAppearance = MacOsAppKitAppearance::apply DesktopAppearanceBridge.applyNativeAppearance = MacOsAppKitAppearance::apply
if (System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) { if (System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) {
DesktopShareBridge.shareFile = MacOsShareSheet::share DesktopShareBridge.shareFile = MacOsShareSheet::share
@@ -16,11 +29,34 @@ fun main() {
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
title = "vnidrop", 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() { private fun configureMacOsNativeAppearance() {
if (!System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) return if (!System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) return
// AWT reads this before creating the first native window. Runtime theme // 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" material3 = "1.11.0-alpha07"
qrcode = "4.5.0" qrcode = "4.5.0"
jna = "5.17.0" jna = "5.17.0"
google-code-scanner = "16.1.0"
[libraries] [libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } 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" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode" } qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } 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] [plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" } androidApplication = { id = "com.android.application", version.ref = "agp" }

View File

@@ -58,16 +58,68 @@ final class VniDropHostViewController: UIViewController {
} }
struct ComposeView: UIViewControllerRepresentable { struct ComposeView: UIViewControllerRepresentable {
let externalInvitations: ExternalInvitationController
func makeUIViewController(context: Self.Context) -> UIViewController { func makeUIViewController(context: Self.Context) -> UIViewController {
VniDropHostViewController(composeController: MainViewControllerKt.MainViewController()) VniDropHostViewController(
composeController: MainViewControllerKt.MainViewController(
externalInvitations: externalInvitations
)
)
} }
func updateUIViewController(_ uiViewController: UIViewController, context: Self.Context) {} func updateUIViewController(_ uiViewController: UIViewController, context: Self.Context) {}
} }
struct ContentView: View { struct ContentView: View {
let externalInvitations: ExternalInvitationController
var body: some View { var body: some View {
ComposeView() ComposeView(externalInvitations: externalInvitations)
.ignoresSafeArea() .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> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <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> <key>UIViewControllerBasedStatusBarAppearance</key>
<true/> <true/>
</dict> </dict>

View File

@@ -1,10 +1,13 @@
import Shared
import SwiftUI import SwiftUI
@main @main
struct iOSApp: App { struct iOSApp: App {
private let externalInvitations = ExternalInvitationController()
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView(externalInvitations: externalInvitations)
} }
} }
} }

View File

@@ -1,6 +1,8 @@
@file:OptIn(gobley.gradle.InternalGobleyGradleApi::class) @file:OptIn(gobley.gradle.InternalGobleyGradleApi::class)
import gobley.gradle.cargo.dsl.appleMobile import gobley.gradle.cargo.dsl.appleMobile
import gobley.gradle.cargo.dsl.jvm
import gobley.gradle.GobleyHost
import gobley.gradle.rust.targets.RustAndroidTarget import gobley.gradle.rust.targets.RustAndroidTarget
import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.PathSensitivity
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
@@ -38,6 +40,7 @@ kotlin {
androidMain.dependencies { androidMain.dependencies {
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.google.code.scanner)
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
} }
commonMain.dependencies { commonMain.dependencies {
@@ -87,6 +90,13 @@ cargo {
packageDirectory = layout.projectDirectory.dir("../crates/vnidrop") packageDirectory = layout.projectDirectory.dir("../crates/vnidrop")
publishJvmArtifacts = true publishJvmArtifacts = true
androidTargetsToBuild.set(setOf(RustAndroidTarget.Arm64)) 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 { builds.appleMobile {
variants { variants {
buildTaskProvider.configure { buildTaskProvider.configure {

View File

@@ -10,10 +10,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import com.vnidrop.app.core.rememberFileSystemService import com.vnidrop.app.core.rememberFileSystemService
import com.vnidrop.app.notifications.rememberAndroidLocalNotificationService import com.vnidrop.app.notifications.rememberAndroidLocalNotificationService
import com.vnidrop.app.feature.receive.ExternalInvitationController
import java.net.NetworkInterface import java.net.NetworkInterface
@Composable @Composable
fun rememberAndroidAppDependencies(activity: ComponentActivity): AppDependencies { fun rememberAndroidAppDependencies(activity: ComponentActivity, externalInvitations: ExternalInvitationController): AppDependencies {
val context = activity.applicationContext val context = activity.applicationContext
val fileSystemService = rememberFileSystemService() val fileSystemService = rememberFileSystemService()
val notificationService = rememberAndroidLocalNotificationService(activity) val notificationService = rememberAndroidLocalNotificationService(activity)
@@ -28,6 +29,7 @@ fun rememberAndroidAppDependencies(activity: ComponentActivity): AppDependencies
deviceInfoProvider = AndroidDeviceInfoProvider(context), deviceInfoProvider = AndroidDeviceInfoProvider(context),
fileSystemService = fileSystemService, fileSystemService = fileSystemService,
localNotificationService = notificationService, 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_refuse">Refuse</string>
<string name="button_approve">Approve</string> <string name="button_approve">Approve</string>
<string name="receive_title">Receive</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="receive_subtitle">Inspect a ticket, request access, and stream files into the output directory.</string>
<string name="ticket_card_title">Ticket</string> <string name="ticket_card_title">Ticket</string>
<string name="field_ticket">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.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.app.AppGraphViewModel
import com.vnidrop.app.feature.approvals.ApprovalModalHost import com.vnidrop.app.feature.approvals.ApprovalModalHost
import com.vnidrop.app.feature.receive.ReceiveRoute 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.ReceiveViewModel
import com.vnidrop.app.feature.receive.ReceiveMethod
import com.vnidrop.app.feature.send.SendRoute import com.vnidrop.app.feature.send.SendRoute
import com.vnidrop.app.feature.send.SendFloatingAction import com.vnidrop.app.feature.send.SendFloatingAction
import com.vnidrop.app.feature.send.SendViewModel 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.state.windowClassFor
import com.vnidrop.app.ui.theme.VniDropTheme import com.vnidrop.app.ui.theme.VniDropTheme
import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
@Composable @Composable
fun App(dependencies: AppDependencies) { fun App(dependencies: AppDependencies) {
@@ -69,8 +74,22 @@ fun App(dependencies: AppDependencies) {
val appState by appViewModel.state.collectAsStateWithLifecycle() val appState by appViewModel.state.collectAsStateWithLifecycle()
val sendState by sendViewModel.state.collectAsStateWithLifecycle() val sendState by sendViewModel.state.collectAsStateWithLifecycle()
val sendCoreState by sendViewModel.coreState.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 approvalState by graph.approvalCoordinator.state.collectAsStateWithLifecycle()
val lifecycleOwner = LocalLifecycleOwner.current 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) { DisposableEffect(lifecycleOwner, graph, settingsViewModel) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
when (event) { when (event) {
@@ -97,6 +116,10 @@ fun App(dependencies: AppDependencies) {
sendCoreState.transfers.any { it.transferId == selectedId } sendCoreState.transfers.any { it.transferId == selectedId }
} != true && } != true &&
sendCoreState.transfers.any { it.direction == TransferDirection.Send } 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( AppShell(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
selectedDestination = appState.destination, selectedDestination = appState.destination,
@@ -112,13 +135,20 @@ fun App(dependencies: AppDependencies) {
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
) )
} }
} else if (showReceiveAction) {
{
ReceiveFloatingAction(
onClick = receiveViewModel::openAcquisition,
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
)
}
} else { } else {
null null
}, },
) { ) {
when (appState.destination) { when (appState.destination) {
AppDestination.Send -> SendRoute(sendViewModel, windowClass) AppDestination.Send -> SendRoute(sendViewModel, windowClass)
AppDestination.Receive -> ScreenScrollContainer { ReceiveRoute(receiveViewModel) } AppDestination.Receive -> ReceiveRoute(receiveViewModel, windowClass)
AppDestination.Settings -> ScreenScrollContainer { SettingsRoute(settingsViewModel, 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.core.FileSystemService
import com.vnidrop.app.notifications.LocalNotificationService import com.vnidrop.app.notifications.LocalNotificationService
import com.vnidrop.app.feature.receive.ExternalInvitationController
data class PlatformEnvironment( data class PlatformEnvironment(
val name: String, val name: String,
@@ -27,4 +28,5 @@ data class AppDependencies(
val deviceInfoProvider: DeviceInfoProvider, val deviceInfoProvider: DeviceInfoProvider,
val fileSystemService: FileSystemService, val fileSystemService: FileSystemService,
val localNotificationService: LocalNotificationService, 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 package com.vnidrop.app.feature.receive
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vnidrop.app.ui.state.WindowClass
@Composable @Composable
fun ReceiveRoute(viewModel: ReceiveViewModel) { fun ReceiveRoute(viewModel: ReceiveViewModel, windowClass: WindowClass) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val coreState by viewModel.coreState.collectAsStateWithLifecycle() val coreState by viewModel.coreState.collectAsStateWithLifecycle()
val actions = rememberReceiveInvitationActions()
DisposableEffect(actions) { onDispose(actions::cancel) }
ReceiveScreen( ReceiveScreen(
coreState = coreState, coreState = coreState,
state = state, state = state,
onTicketChanged = viewModel::setTicket, windowClass = windowClass,
actions = actions,
onOpenAcquisition = viewModel::openAcquisition,
onDismissAcquisition = {
actions.cancel()
viewModel.dismissAcquisition()
},
onReceiverNameChanged = viewModel::setReceiverName, onReceiverNameChanged = viewModel::setReceiverName,
onInspectTicket = viewModel::inspectTicket, onInvitationResult = viewModel::onInvitationResult,
onWaitingForNfc = viewModel::setWaitingForNfc,
onReceive = viewModel::receive, 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 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row 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.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 androidx.compose.ui.unit.dp
import com.vnidrop.app.core.CoreState import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.FolderAccessStatus 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.Field
import com.vnidrop.app.ui.components.MetadataRow
import com.vnidrop.app.ui.components.PrimaryButton import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.SecondaryButton import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.screens.ProgressSection import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.screens.ScreenHeader import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.screens.TicketInspectionCard import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.*
import vnidrop.shared.generated.resources.button_inspect_ticket
import vnidrop.shared.generated.resources.button_receive @Composable
import vnidrop.shared.generated.resources.button_receiving fun ReceiveFloatingAction(onClick: () -> Unit, modifier: Modifier = Modifier) {
import vnidrop.shared.generated.resources.field_output_directory FloatingActionButton(
import vnidrop.shared.generated.resources.field_receiver_name onClick = onClick,
import vnidrop.shared.generated.resources.field_ticket modifier = modifier,
import vnidrop.shared.generated.resources.folder_status_permission_required containerColor = LocalVniDropColors.current.brandButton,
import vnidrop.shared.generated.resources.folder_status_unavailable contentColor = Color.White,
import vnidrop.shared.generated.resources.folder_status_writable ) { Icon(ReceiveIcons.Download, stringResource(Res.string.button_receive_files)) }
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
@Composable @Composable
fun ReceiveScreen( fun ReceiveScreen(
coreState: CoreState, coreState: CoreState,
state: ReceiveState, state: ReceiveState,
onTicketChanged: (String) -> Unit, windowClass: WindowClass,
actions: ReceiveInvitationActions,
onOpenAcquisition: () -> Unit,
onDismissAcquisition: () -> Unit,
onReceiverNameChanged: (String) -> Unit, onReceiverNameChanged: (String) -> Unit,
onInspectTicket: () -> Unit, onInvitationResult: (ReceiveMethod, Result<String>) -> Unit,
onWaitingForNfc: (Boolean) -> Unit,
onReceive: () -> Unit, onReceive: () -> Unit,
onRequestDeleteHistoryItem: (ULong) -> Unit,
onRequestClearHistory: () -> Unit,
onDismissHistoryDelete: () -> Unit,
onConfirmHistoryDelete: () -> Unit,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { val transfers = coreState.transfers.filter { it.direction == TransferDirection.Receive }
ScreenHeader(stringResource(Res.string.receive_title), stringResource(Res.string.receive_subtitle)) val deletableTransfers = transfers.filter { it.status.isTerminalReceiveHistory() }
AppCard(title = stringResource(Res.string.ticket_card_title)) { LazyColumn(
Field(state.ticket, onTicketChanged, stringResource(Res.string.field_ticket), minLines = 4) modifier = Modifier.fillMaxSize().statusBarsPadding(),
MetadataRow( contentPadding = PaddingValues(16.dp),
stringResource(Res.string.field_output_directory), verticalArrangement = Arrangement.spacedBy(14.dp),
state.receiveFolder?.displayName?.ifBlank { state.outputDirectory } ?: state.outputDirectory, ) {
) item { ReceiveHeader(transfers.isNotEmpty(), windowClass, onOpenAcquisition) }
MetadataRow(stringResource(Res.string.metadata_status), state.folderAccessStatus.displayName()) if (transfers.isEmpty()) item { ReceiveEmptyState(onOpenAcquisition) }
Field(state.receiverName, onReceiverNameChanged, stringResource(Res.string.field_receiver_name)) else {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { item {
SecondaryButton( Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
stringResource(Res.string.button_inspect_ticket), Text(stringResource(Res.string.receive_history_title), modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
onClick = onInspectTicket, if (deletableTransfers.isNotEmpty()) DestructiveQuietButton(stringResource(Res.string.receive_clear_history), onClick = onRequestClearHistory)
enabled = state.canInspect(coreState.isInitialized), }
}
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( } else {
if (state.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive), InvitationReviewPanel(
onClick = onReceive, state = state,
enabled = state.canReceive(coreState.isInitialized), 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 @Composable
private fun FolderAccessStatus.displayName(): String = when (this) { private fun ReceiveHeader(showAction: Boolean, windowClass: WindowClass, onOpen: () -> Unit) {
FolderAccessStatus.Writable -> stringResource(Res.string.folder_status_writable) Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
FolderAccessStatus.PermissionRequired -> stringResource(Res.string.folder_status_permission_required) Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
FolderAccessStatus.Unavailable -> stringResource(Res.string.folder_status_unavailable) 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.FolderAccessStatus
import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind 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.preferences.PreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessage
import com.vnidrop.app.ui.feedback.UiMessageController 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch 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( data class ReceiveState(
val isAcquisitionOpen: Boolean = false,
val ticket: String = "", val ticket: String = "",
val outputDirectory: String = "", val method: ReceiveMethod? = null,
val inspection: TicketInspectionModel? = null,
val receiverName: String = "", val receiverName: String = "",
val receiveFolder: ReceiveFolder? = null, val receiveFolder: ReceiveFolder? = null,
val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable, val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable,
val isInspecting: Boolean = false,
val isReceiving: 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 = fun canReceive(coreInitialized: Boolean): Boolean =
coreInitialized && ticket.isNotBlank() && outputDirectory.isNotBlank() && coreInitialized && ticket.isNotBlank() && inspection != null &&
folderAccessStatus == FolderAccessStatus.Writable && !isReceiving folderAccessStatus == FolderAccessStatus.Writable && !isReceiving && !isInspecting
} }
class ReceiveViewModel( class ReceiveViewModel(
@@ -47,7 +67,6 @@ class ReceiveViewModel(
current.copy( current.copy(
receiverName = current.receiverName.ifBlank { preferences.username }, receiverName = current.receiverName.ifBlank { preferences.username },
receiveFolder = preferences.receiveFolder, receiveFolder = preferences.receiveFolder,
outputDirectory = preferences.receiveFolder.value,
folderAccessStatus = status, folderAccessStatus = status,
) )
} }
@@ -55,14 +74,58 @@ class ReceiveViewModel(
} }
} }
fun setTicket(value: String) = _state.update { it.copy(ticket = value) } fun openAcquisition() = _state.update { it.copy(isAcquisitionOpen = true) }
fun setOutputDirectory(value: String) = _state.update { it.copy(outputDirectory = value) } fun dismissAcquisition() {
if (!_state.value.isReceiving && !_state.value.isInspecting) resetAcquisition()
}
fun setReceiverName(value: String) = _state.update { it.copy(receiverName = value) } 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() { fun onInvitationResult(method: ReceiveMethod, result: Result<String>) {
val current = state.value _state.update { it.copy(isWaitingForNfc = false) }
if (!current.canInspect(coreState.value.isInitialized)) return result.fold(
viewModelScope.launch { repository.inspectTicket(current.ticket).onFailure(messages::error) } onSuccess = { raw -> inspectInvitation(method, raw) },
onFailure = messages::error,
)
} }
fun receive() { fun receive() {
@@ -71,21 +134,64 @@ class ReceiveViewModel(
if (!current.canReceive(coreState.value.isInitialized)) return if (!current.canReceive(coreState.value.isInitialized)) return
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isReceiving = true) } _state.update { it.copy(isReceiving = true) }
try { val outputSink = fileSystemService.createReceiveOutputSink(folder)
val outputSink = fileSystemService.createReceiveOutputSink(folder) val result = when {
val result = when { outputSink != null -> repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName)
outputSink != null -> repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName) folder.kind == ReceiveFolderKind.IosSecurityScopedUrl -> repository.receiveIntoSecurityScopedDirectory(
folder.kind == ReceiveFolderKind.IosSecurityScopedUrl -> repository.receiveIntoSecurityScopedDirectory( current.ticket,
current.ticket, folder.value,
folder.value, current.receiverName,
current.receiverName, )
) else -> repository.receive(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) }
} }
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 @Composable
fun DestructiveButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) { fun DestructiveButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
Button( Button(

View File

@@ -7,7 +7,11 @@ import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.core.Share import com.vnidrop.app.core.Share
import com.vnidrop.app.core.ShareAccessPolicy 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.app.AppViewModel
import com.vnidrop.app.feature.receive.ReceiveHistoryDeleteTarget
import com.vnidrop.app.feature.receive.ReceiveViewModel import com.vnidrop.app.feature.receive.ReceiveViewModel
import com.vnidrop.app.feature.send.SendViewModel import com.vnidrop.app.feature.send.SendViewModel
import com.vnidrop.app.feature.settings.SettingsViewModel import com.vnidrop.app.feature.settings.SettingsViewModel
@@ -227,20 +231,105 @@ class ViewModelsTest {
@Test @Test
fun receiveViewModelBuildsStateFromPreferences() = runTest { fun receiveViewModelBuildsStateFromPreferences() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) 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()) val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
advanceUntilIdle() advanceUntilIdle()
viewModel.setTicket("ticket") viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.InvitationFile, Result.success("ticket"))
advanceUntilIdle()
assertTrue(viewModel.state.value.canReceive(coreInitialized = true)) assertTrue(viewModel.state.value.canReceive(coreInitialized = true))
assertEquals("Receiver", viewModel.state.value.receiverName) 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( private fun preferences() = FakePreferencesRepository(
AppPreferences("Receiver", folder, ThemeMode.System, notificationsEnabled = false), AppPreferences("Receiver", folder, ThemeMode.System, notificationsEnabled = false),
) )
private fun environment() = PlatformEnvironment("Test", "1.0", "/tmp/vnidrop") 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 { private companion object {
val folder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "Downloads") 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) var responseResult: Result<Unit> = Result.success(Unit)
val responses = mutableListOf<Triple<String, Boolean, String?>>() val responses = mutableListOf<Triple<String, Boolean, String?>>()
var shareResult: Result<Share> = Result.failure(UnsupportedOperationException()) var shareResult: Result<Share> = Result.failure(UnsupportedOperationException())
var inspectionResult: Result<TicketInspectionModel> = Result.failure(UnsupportedOperationException())
var deleteResult: Result<Unit> = Result.success(Unit) var deleteResult: Result<Unit> = Result.success(Unit)
var clearReceiveHistoryResult: Result<ULong> = Result.success(0UL)
val deletedTransfers = mutableListOf<ULong>() val deletedTransfers = mutableListOf<ULong>()
var clearReceiveHistoryCount = 0
var lastShareAccessPolicy: ShareAccessPolicy? = null var lastShareAccessPolicy: ShareAccessPolicy? = null
override suspend fun initialize(appDataDir: String): Result<Unit> { override suspend fun initialize(appDataDir: String): Result<Unit> {
@@ -84,7 +87,7 @@ class FakeCoreGateway : CoreGateway {
senderName: String, senderName: String,
accessPolicy: ShareAccessPolicy, accessPolicy: ShareAccessPolicy,
) = Result.failure<Share>(UnsupportedOperationException()) ) = 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 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 receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = Result.success(Unit)
override suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) = Result.success(Unit) override suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) = Result.success(Unit)
@@ -98,6 +101,21 @@ class FakeCoreGateway : CoreGateway {
} }
return deleteResult 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 receiverRequests(transferId: ULong) = Result.success(requests[transferId].orEmpty())
override suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String?): Result<Unit> { override suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String?): Result<Unit> {
responses += Triple(requestId, accepted, reason) responses += Triple(requestId, accepted, reason)

View File

@@ -60,15 +60,14 @@ class AppUiModelsTest {
fun receiveStateExposesInspectAndReceiveEligibility() { fun receiveStateExposesInspectAndReceiveEligibility() {
val ready = ReceiveState( val ready = ReceiveState(
ticket = "ticket", ticket = "ticket",
outputDirectory = "/tmp/out", inspection = com.vnidrop.app.core.TicketInspectionModel("vnidrop", "blob", null),
folderAccessStatus = com.vnidrop.app.core.FolderAccessStatus.Writable, folderAccessStatus = com.vnidrop.app.core.FolderAccessStatus.Writable,
) )
assertTrue(ready.canInspect(coreInitialized = true))
assertTrue(ready.canReceive(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(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)) assertFalse(ready.copy(isReceiving = true).canReceive(coreInitialized = true))
} }

View File

@@ -1,5 +1,7 @@
package com.vnidrop.app package com.vnidrop.app
import androidx.compose.ui.window.ComposeUIViewController 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 androidx.compose.runtime.remember
import com.vnidrop.app.core.rememberFileSystemService import com.vnidrop.app.core.rememberFileSystemService
import com.vnidrop.app.notifications.IosLocalNotificationService import com.vnidrop.app.notifications.IosLocalNotificationService
import com.vnidrop.app.feature.receive.ExternalInvitationController
import platform.Foundation.NSBundle import platform.Foundation.NSBundle
import platform.Foundation.NSApplicationSupportDirectory import platform.Foundation.NSApplicationSupportDirectory
import platform.Foundation.NSSearchPathForDirectoriesInDomains import platform.Foundation.NSSearchPathForDirectoriesInDomains
@@ -11,7 +12,7 @@ import platform.Foundation.NSUserDomainMask
import platform.UIKit.UIDevice import platform.UIKit.UIDevice
@Composable @Composable
fun rememberIosAppDependencies(): AppDependencies { fun rememberIosAppDependencies(externalInvitations: ExternalInvitationController): AppDependencies {
val fileSystemService = rememberFileSystemService() val fileSystemService = rememberFileSystemService()
return remember(fileSystemService) { return remember(fileSystemService) {
val device = UIDevice.currentDevice val device = UIDevice.currentDevice
@@ -25,6 +26,7 @@ fun rememberIosAppDependencies(): AppDependencies {
deviceInfoProvider = IosDeviceInfoProvider(device), deviceInfoProvider = IosDeviceInfoProvider(device),
fileSystemService = fileSystemService, fileSystemService = fileSystemService,
localNotificationService = IosLocalNotificationService(), 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 androidx.compose.runtime.remember
import com.vnidrop.app.core.rememberFileSystemService import com.vnidrop.app.core.rememberFileSystemService
import com.vnidrop.app.notifications.JvmLocalNotificationService import com.vnidrop.app.notifications.JvmLocalNotificationService
import com.vnidrop.app.feature.receive.ExternalInvitationController
import java.net.NetworkInterface import java.net.NetworkInterface
@Composable @Composable
fun rememberJvmAppDependencies(): AppDependencies { fun rememberJvmAppDependencies(externalInvitations: ExternalInvitationController): AppDependencies {
val fileSystemService = rememberFileSystemService() val fileSystemService = rememberFileSystemService()
return remember(fileSystemService) { return remember(fileSystemService) {
AppDependencies( AppDependencies(
@@ -20,6 +21,7 @@ fun rememberJvmAppDependencies(): AppDependencies {
deviceInfoProvider = JvmDeviceInfoProvider, deviceInfoProvider = JvmDeviceInfoProvider,
fileSystemService = fileSystemService, fileSystemService = fileSystemService,
localNotificationService = JvmLocalNotificationService(), 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.ApprovalModalHost
import com.vnidrop.app.feature.approvals.ApprovalState import com.vnidrop.app.feature.approvals.ApprovalState
import com.vnidrop.app.feature.approvals.PendingApproval 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.SettingsScreen
import com.vnidrop.app.feature.settings.SettingsSection import com.vnidrop.app.feature.settings.SettingsSection
import com.vnidrop.app.feature.settings.SettingsState import com.vnidrop.app.feature.settings.SettingsState
@@ -316,6 +321,46 @@ class FoundationComposeTest {
onNodeWithContentDescription("Close").assertIsDisplayed() 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 @Test
fun snackbarActionAndCancellationAreForwarded() = runComposeUiTest { fun snackbarActionAndCancellationAreForwarded() = runComposeUiTest {
val controller = UiMessageController() val controller = UiMessageController()
@@ -361,4 +406,20 @@ class FoundationComposeTest {
createdAt = 1L, createdAt = 1L,
updatedAt = 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,
)
} }