feat(shared): graduate saved-device transfer experience

Add the VniDrop-specific Compose architecture skill, unify invitation and targeted transfer drafts, and promote Saved devices to an adaptive first-class destination.
This commit is contained in:
2026-08-12 21:45:18 +02:00
parent 2ac9166b34
commit 6ab658fea2
105 changed files with 2748 additions and 9764 deletions

View File

@@ -14,18 +14,18 @@ platforms use the native SwiftUI app under `apple/`.
---
## Compose skill (required for UI work)
## VniDrop KMP UI skill (required for UI work)
For screens, components, theme, navigation, resources, ViewModel↔UI wiring,
lists, animation, accessibility:
1. Load [`.codex/skills/compose-skill/SKILL.md`](../.codex/skills/compose-skill/SKILL.md).
2. Follow its workflow and defaults.
2. Follow its VniDrop-specific workflow and defaults.
3. Open **at most one** file under `.codex/skills/compose-skill/references/` when
the skills Quick Routing table says you need deeper guidance.
the skill links to it for the current task.
4. Do **not** invent a parallel Compose style guide.
### Project policy (overrides generic skill defaults)
### Project policy
| Topic | Do this |
|-------|---------|
@@ -37,7 +37,13 @@ lists, animation, accessibility:
| Platform | `androidMain` / `jvmMain` for pickers, SAF, NFC/QR, and desktop integration. |
| Dependencies | Before adding Jetpack/AndroidX to `commonMain`, verify multiplatform artifacts for all targets. |
compose-skill “Existing Project Policy”: adapt to this repo; do not force-migrate.
The skill is repository-specific. Do not substitute a generic Compose/MVI style guide.
Platform-native presentation is more important than maximizing shared UI code.
Use Material icons and conventions on Android, Fluent on Windows, and the existing
Lucide/desktop conventions on Linux. Repeated platform presentation code is
acceptable when sharing would make a platform feel foreign; domain behavior and
state machines must remain shared.
---
@@ -82,7 +88,8 @@ src/
- **Android share:** open content URIs as FDs; expand **folder trees** to per-file
documents with relative `displayName` paths before calling Rust
(`FileSystemService.android.kt` / `expandShareDirectory`).
(`PickedShareSourceAdapter.android.kt` / `expandShareDirectory`). Transfer creation
uses the focused `PickedShareSourceAdapter`; `FileSystemService` owns receive storage.
- **Android receive:** MediaStore Downloads sink and/or SAF tree write sink.
- **Apple:** lives outside this module under `apple/`; do not add Apple platform
behavior back to KMP.

View File

@@ -21,7 +21,10 @@ actual fun rememberShareFilePicker(
): ShareFilePicker {
val context = LocalContext.current
val filesLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
if (uris.isEmpty()) return@rememberLauncherForActivityResult
if (uris.isEmpty()) {
onFilesPicked(emptyList())
return@rememberLauncherForActivityResult
}
runCatching {
uris.map { uri -> context.pickedShareFile(uri) }
}.fold(
@@ -30,7 +33,10 @@ actual fun rememberShareFilePicker(
)
}
val folderLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
if (uri == null) {
onFilesPicked(emptyList())
return@rememberLauncherForActivityResult
}
runCatching {
// Read permission only — we expand the tree into file FDs at share time.
context.contentResolver.takePersistableUriPermission(

View File

@@ -55,9 +55,9 @@ private class AndroidFileSystemService(
override suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus =
when (folder.kind) {
ReceiveFolderKind.FileSystemPath -> validatePath(folder.value)
ReceiveFolderKind.AndroidPublicDownloads -> validatePublicDownloads()
ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value)
ReceiveFolderKind.FileSystemPath -> context.validatePath(folder.value)
ReceiveFolderKind.AndroidPublicDownloads -> context.validatePublicDownloads()
ReceiveFolderKind.AndroidTreeUri -> context.validateTreeUri(folder.value)
}
override suspend fun inspectReceivedArtifacts(artifacts: List<ReceivedArtifactModel>): ReceivedStorageInspection {
@@ -135,63 +135,15 @@ private class AndroidFileSystemService(
ReceiveFolderKind.AndroidTreeUri -> AndroidTreeReceiveOutputSink(context, folder.value.toUri())
ReceiveFolderKind.FileSystemPath -> null
}
}
override suspend fun sharePickedFiles(
repository: CoreGateway,
files: List<PickedShareFile>,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> = withAndroidShareSources(files) { sources ->
repository.shareSources(sources, transferName, senderName, accessPolicy).getOrThrow()
}
override suspend fun createTargetedTransferFromPickedFiles(
repository: CoreGateway,
receiverEndpointId: String,
files: List<PickedShareFile>,
transferName: String?,
): Result<TargetedTransferModel> = withAndroidShareSources(files) { sources ->
repository.createTargetedTransfer(receiverEndpointId, sources, transferName).getOrThrow()
}
private suspend fun <T> withAndroidShareSources(
files: List<PickedShareFile>,
block: suspend (List<uniffi.vnidrop.ShareSource>) -> T,
): Result<T> = runCatching {
require(files.isNotEmpty()) { "Select at least one file to share" }
// Android cannot pass a directory as a single FD. Expand SAF trees into
// individual document files with relative collection paths, then open FDs.
val expanded = files.flatMap { file ->
if (file.isDirectory) context.expandShareDirectory(file) else listOf(file)
}
require(expanded.isNotEmpty()) { "No files found in the selected folder" }
val descriptors = expanded.map { file ->
context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r")
?: error("Could not open selected file descriptor for ${file.displayName}")
}
try {
val sources = expanded.zip(descriptors) { file, descriptor ->
uniffi.vnidrop.ShareSource(
kind = uniffi.vnidrop.SourceKind.FILE_DESCRIPTOR,
value = descriptor.fd.toString(),
displayName = file.displayName,
isDirectory = false,
)
}
block(sources)
} finally {
descriptors.forEach { it.close() }
}
}
/**
/**
* Probe a real create/write/delete instead of [File.canWrite].
*
* Scoped storage often reports public directories as writable even when
* the process cannot create files there. A probe matches what receive needs.
*/
private fun validatePath(path: String): FolderAccessStatus =
private fun Context.validatePath(path: String): FolderAccessStatus =
runCatching {
val directory = File(path)
if (!directory.exists() && !directory.mkdirs()) {
@@ -208,24 +160,24 @@ private class AndroidFileSystemService(
}
}.getOrDefault(FolderAccessStatus.Unavailable)
private fun validatePublicDownloads(): FolderAccessStatus =
private fun Context.validatePublicDownloads(): FolderAccessStatus =
runCatching {
val probeName = ".vnidrop-write-test-${UUID.randomUUID()}"
val sink = AndroidMediaStoreDownloadsSink(context)
val sink = AndroidMediaStoreDownloadsSink(this)
sink.startFile(probeName)
sink.writeChunk(probeName, byteArrayOf(1))
sink.abortFile(probeName, "write probe complete")
FolderAccessStatus.Writable
}.getOrDefault(FolderAccessStatus.Unavailable)
private fun validateTreeUri(value: String): FolderAccessStatus {
private fun Context.validateTreeUri(value: String): FolderAccessStatus {
val uri = Uri.parse(value)
val hasPermission = context.contentResolver.persistedUriPermissions.any { permission ->
val hasPermission = contentResolver.persistedUriPermissions.any { permission ->
permission.uri == uri && permission.isWritePermission
}
if (!hasPermission) return FolderAccessStatus.PermissionRequired
return runCatching {
val probe = AndroidTreeReceiveOutputSink(context, uri)
val probe = AndroidTreeReceiveOutputSink(this, uri)
val probeName = ".vnidrop-write-test-${UUID.randomUUID()}"
probe.startFile(probeName)
probe.writeChunk(probeName, byteArrayOf())
@@ -233,64 +185,6 @@ private class AndroidFileSystemService(
FolderAccessStatus.Writable
}.getOrDefault(FolderAccessStatus.Unavailable)
}
}
/**
* Expand a SAF document tree into individual file documents.
*
* Rust cannot accept a directory FD. Collection paths preserve the folder
* root name so receivers see `Folder/nested/file.txt`.
*/
private fun Context.expandShareDirectory(folder: PickedShareFile): List<PickedShareFile> {
val treeUri = Uri.parse(folder.value)
val rootId = DocumentsContract.getTreeDocumentId(treeUri)
val out = mutableListOf<PickedShareFile>()
fun walk(documentId: String, relativePath: String) {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, documentId)
contentResolver.query(
childrenUri,
arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_SIZE,
),
null,
null,
null,
)?.use { cursor ->
val idIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val nameIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val mimeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE)
val sizeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE)
while (cursor.moveToNext()) {
val id = cursor.getString(idIndex) ?: continue
val name = cursor.getString(nameIndex) ?: continue
val mime = cursor.getString(mimeIndex)
val childRelative = if (relativePath.isEmpty()) name else "$relativePath/$name"
if (mime == DocumentsContract.Document.MIME_TYPE_DIR) {
walk(id, childRelative)
} else {
val documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, id)
val size = if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) {
cursor.getLong(sizeIndex).takeIf { it >= 0L }?.toULong()
} else {
null
}
out += PickedShareFile(
value = documentUri.toString(),
displayName = childRelative,
sizeBytes = size,
isDirectory = false,
)
}
}
}
}
// Prefix paths with the folder display name so nested structure is preserved.
walk(rootId, folder.displayName)
return out
}
private inline fun <T> receiveSinkCall(block: () -> T): T =
try {

View File

@@ -0,0 +1,108 @@
package com.vnidrop.app.core
import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
@Composable
internal actual fun rememberPickedShareSourceAdapter(): PickedShareSourceAdapter {
val context = LocalContext.current.applicationContext
return remember(context) { AndroidPickedShareSourceAdapter(context) }
}
private class AndroidPickedShareSourceAdapter(
private val context: Context,
) : PickedShareSourceAdapter {
override suspend fun <T> withShareSources(
files: List<PickedShareFile>,
operation: suspend (List<uniffi.vnidrop.ShareSource>) -> T,
): Result<T> = runCatching {
require(files.isNotEmpty()) { "Select at least one file to share" }
// Android cannot pass a directory as a single FD. Expand SAF trees into
// individual document files with relative collection paths, then open FDs.
val expanded = files.flatMap { file ->
if (file.isDirectory) context.expandShareDirectory(file) else listOf(file)
}
require(expanded.isNotEmpty()) { "No files found in the selected folder" }
val descriptors = expanded.map { file ->
context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r")
?: error("Could not open selected file descriptor for ${file.displayName}")
}
try {
val sources = expanded.zip(descriptors) { file, descriptor ->
uniffi.vnidrop.ShareSource(
kind = uniffi.vnidrop.SourceKind.FILE_DESCRIPTOR,
value = descriptor.fd.toString(),
displayName = file.displayName,
isDirectory = false,
)
}
operation(sources)
} finally {
descriptors.forEach { it.close() }
}
}
// Android's picker returns content owned by the user, so there is no app copy to delete.
override suspend fun discardPickedFiles(files: List<PickedShareFile>) = Unit
}
/**
* Expand a SAF document tree into individual file documents.
*
* Rust cannot accept a directory FD. Collection paths preserve the folder
* root name so receivers see `Folder/nested/file.txt`.
*/
private fun Context.expandShareDirectory(folder: PickedShareFile): List<PickedShareFile> {
val treeUri = Uri.parse(folder.value)
val rootId = DocumentsContract.getTreeDocumentId(treeUri)
val out = mutableListOf<PickedShareFile>()
fun walk(documentId: String, relativePath: String) {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, documentId)
contentResolver.query(
childrenUri,
arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_SIZE,
),
null,
null,
null,
)?.use { cursor ->
val idIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val nameIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val mimeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE)
val sizeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE)
while (cursor.moveToNext()) {
val id = cursor.getString(idIndex) ?: continue
val name = cursor.getString(nameIndex) ?: continue
val mime = cursor.getString(mimeIndex)
val childRelative = if (relativePath.isEmpty()) name else "$relativePath/$name"
if (mime == DocumentsContract.Document.MIME_TYPE_DIR) {
walk(id, childRelative)
} else {
val documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, id)
val size = if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) {
cursor.getLong(sizeIndex).takeIf { it >= 0L }?.toULong()
} else {
null
}
out += PickedShareFile(
value = documentUri.toString(),
displayName = childRelative,
sizeBytes = size,
isDirectory = false,
)
}
}
}
}
// Prefix paths with the folder display name so nested structure is preserved.
walk(rootId, folder.displayName)
return out
}

View File

@@ -100,9 +100,6 @@
<string name="error_starting_up">VniDrop startet noch. Öffnen Sie die Einladung gleich erneut.</string>
<string name="error_storage_full">Zum Speichern dieser Übertragung ist nicht genügend Speicherplatz vorhanden. Geben Sie Speicherplatz frei und versuchen Sie es erneut.</string>
<string name="error_transfer">Die Übertragungsdaten konnten nicht verarbeitet werden. Bitten Sie den Absender, sie erneut zu teilen.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Empfängername</string>
<string name="field_sender_name">Absendername</string>
<string name="field_transfer_name">Übertragungsname</string>
@@ -115,6 +112,7 @@
<string name="metadata_size">Größe</string>
<string name="metadata_status">Status</string>
<string name="nav_receive">Empfangen</string>
<string name="nav_saved_devices">Geräte</string>
<string name="nav_send">Senden</string>
<string name="nav_settings">Einstellungen</string>
<string name="network_title">Netzwerk</string>
@@ -221,6 +219,7 @@
<string name="send_new_transfer_description">Eine neue Übertragung erstellen</string>
<string name="send_new_transfer_title">Neue Übertragung</string>
<string name="send_review_title">Übertragung prüfen</string>
<string name="send_default_transfer_name">%1$d Dateien</string>
<string name="send_selected_files_count">%1$d Dateien ausgewählt</string>
<string name="send_stop_sharing">Freigabe beenden</string>
<string name="send_stop_sharing_description">Dies beendet die Übertragung und unterbricht alle, die sie gerade herunterladen. Sie verbleibt in Ihrem Verlauf als „Beendet“.</string>
@@ -320,10 +319,21 @@
<string name="offer_body">%1$s möchte Ihnen „%2$s“ senden.</string>
<string name="offer_accept">Empfangen</string>
<string name="offer_decline">Ablehnen</string>
<string name="saved_devices_authenticated_name">Remote-Name: %1$s</string>
<string name="saved_devices_block_confirm_body">%1$s blockieren und künftige Übertragungen gespeicherter Geräte ablehnen?</string>
<string name="saved_devices_block_confirm_title">Gerät blockieren?</string>
<string name="saved_devices_description">Direkt an vertrauenswürdige Geräte senden, ohne eine neue Einladung zu teilen.</string>
<string name="saved_devices_endpoint">Geräte-ID: %1$s</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_forget_confirm_body">%1$s vergessen? Vor einer weiteren direkten Übertragung müssen beide das Speichern erneut bestätigen.</string>
<string name="saved_devices_forget_confirm_title">Gerät vergessen?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_load_failed">Gespeicherte Geräte konnten nicht geladen werden.</string>
<string name="saved_devices_loading">Gespeicherte Geräte werden geladen…</string>
<string name="saved_devices_more_actions">Weitere Aktionen für %1$s</string>
<string name="saved_devices_no_pending">Keine Kopplungsanfragen benötigen Ihre Aufmerksamkeit.</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>

View File

@@ -100,9 +100,6 @@
<string name="error_starting_up">VniDrop todavía se está iniciando. Vuelva a abrir la invitación en un momento.</string>
<string name="error_storage_full">No hay suficiente espacio de almacenamiento para guardar esta transferencia. Libere espacio e inténtelo de nuevo.</string>
<string name="error_transfer">No se pudieron procesar los datos de la transferencia. Pida al remitente que vuelva a compartirlos.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Nombre del destinatario</string>
<string name="field_sender_name">Nombre del remitente</string>
<string name="field_transfer_name">Nombre de la transferencia</string>
@@ -115,6 +112,7 @@
<string name="metadata_size">Tamaño</string>
<string name="metadata_status">Estado</string>
<string name="nav_receive">Recibir</string>
<string name="nav_saved_devices">Dispositivos</string>
<string name="nav_send">Enviar</string>
<string name="nav_settings">Ajustes</string>
<string name="network_title">Red</string>
@@ -221,6 +219,7 @@
<string name="send_new_transfer_description">Crear una nueva transferencia</string>
<string name="send_new_transfer_title">Nueva transferencia</string>
<string name="send_review_title">Revisar transferencia</string>
<string name="send_default_transfer_name">%1$d archivos</string>
<string name="send_selected_files_count">%1$d archivos seleccionados</string>
<string name="send_stop_sharing">Dejar de compartir</string>
<string name="send_stop_sharing_description">Esto detiene la transferencia e interrumpe a quien esté descargándola. Permanece en su historial como Detenida.</string>
@@ -320,10 +319,21 @@
<string name="offer_body">%1$s quiere enviarte «%2$s».</string>
<string name="offer_accept">Recibir</string>
<string name="offer_decline">Rechazar</string>
<string name="saved_devices_authenticated_name">Nombre remoto: %1$s</string>
<string name="saved_devices_block_confirm_body">¿Bloquear a %1$s y rechazar futuros envíos de dispositivos guardados?</string>
<string name="saved_devices_block_confirm_title">¿Bloquear dispositivo?</string>
<string name="saved_devices_description">Envía directamente a dispositivos de confianza sin compartir otra invitación.</string>
<string name="saved_devices_endpoint">ID del dispositivo: %1$s</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_forget_confirm_body">¿Olvidar a %1$s? Ambos deberán volver a aprobar el guardado antes de otra transferencia directa.</string>
<string name="saved_devices_forget_confirm_title">¿Olvidar dispositivo?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_load_failed">No se pudieron cargar los dispositivos guardados.</string>
<string name="saved_devices_loading">Cargando dispositivos guardados…</string>
<string name="saved_devices_more_actions">Más acciones para %1$s</string>
<string name="saved_devices_no_pending">No hay solicitudes de vinculación que requieran tu atención.</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>

View File

@@ -100,9 +100,6 @@
<string name="error_starting_up">VniDrop démarre encore. Rouvrez linvitation dans un instant.</string>
<string name="error_storage_full">Lespace de stockage est insuffisant pour enregistrer ce transfert. Libérez de lespace et réessayez.</string>
<string name="error_transfer">Les données du transfert nont pas pu être traitées. Demandez à lexpéditeur de les partager à nouveau.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Nom du destinataire</string>
<string name="field_sender_name">Nom de lexpéditeur</string>
<string name="field_transfer_name">Nom du transfert</string>
@@ -115,6 +112,7 @@
<string name="metadata_size">Taille</string>
<string name="metadata_status">Statut</string>
<string name="nav_receive">Recevoir</string>
<string name="nav_saved_devices">Appareils</string>
<string name="nav_send">Envoyer</string>
<string name="nav_settings">Réglages</string>
<string name="network_title">Réseau</string>
@@ -221,6 +219,7 @@
<string name="send_new_transfer_description">Créer un nouveau transfert</string>
<string name="send_new_transfer_title">Nouveau transfert</string>
<string name="send_review_title">Vérifier le transfert</string>
<string name="send_default_transfer_name">%1$d fichiers</string>
<string name="send_selected_files_count">%1$d fichiers sélectionnés</string>
<string name="send_stop_sharing">Arrêter le partage</string>
<string name="send_stop_sharing_description">Cela arrête le transfert et interrompt toute personne en train de le télécharger. Il reste dans votre historique en tant quArrêté.</string>
@@ -320,10 +319,21 @@
<string name="offer_body">%1$s souhaite vous envoyer « %2$s ».</string>
<string name="offer_accept">Recevoir</string>
<string name="offer_decline">Refuser</string>
<string name="saved_devices_authenticated_name">Nom distant : %1$s</string>
<string name="saved_devices_block_confirm_body">Bloquer %1$s et refuser les futurs transferts dappareil enregistré ?</string>
<string name="saved_devices_block_confirm_title">Bloquer lappareil ?</string>
<string name="saved_devices_description">Envoyez directement aux appareils de confiance, sans partager une nouvelle invitation.</string>
<string name="saved_devices_endpoint">ID de lappareil : %1$s</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_forget_confirm_body">Oublier %1$s ? Vous devrez tous les deux approuver à nouveau lenregistrement avant un autre transfert direct.</string>
<string name="saved_devices_forget_confirm_title">Oublier lappareil ?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_load_failed">Impossible de charger les appareils enregistrés.</string>
<string name="saved_devices_loading">Chargement des appareils enregistrés…</string>
<string name="saved_devices_more_actions">Plus dactions pour %1$s</string>
<string name="saved_devices_no_pending">Aucune demande dassociation ne nécessite votre attention.</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>

View File

@@ -100,9 +100,6 @@
<string name="error_starting_up">VniDrop è ancora in fase di avvio. Riapra linvito tra un momento.</string>
<string name="error_storage_full">Lo spazio di archiviazione non è sufficiente per salvare il trasferimento. Liberi spazio e riprovi.</string>
<string name="error_transfer">Non è stato possibile elaborare i dati del trasferimento. Chieda al mittente di condividerli di nuovo.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Nome del destinatario</string>
<string name="field_sender_name">Nome del mittente</string>
<string name="field_transfer_name">Nome del trasferimento</string>
@@ -115,6 +112,7 @@
<string name="metadata_size">Dimensione</string>
<string name="metadata_status">Stato</string>
<string name="nav_receive">Ricevi</string>
<string name="nav_saved_devices">Dispositivi</string>
<string name="nav_send">Invia</string>
<string name="nav_settings">Impostazioni</string>
<string name="network_title">Rete</string>
@@ -221,6 +219,7 @@
<string name="send_new_transfer_description">Crea un nuovo trasferimento</string>
<string name="send_new_transfer_title">Nuovo trasferimento</string>
<string name="send_review_title">Rivedi trasferimento</string>
<string name="send_default_transfer_name">%1$d file</string>
<string name="send_selected_files_count">%1$d file selezionati</string>
<string name="send_stop_sharing">Interrompi condivisione</string>
<string name="send_stop_sharing_description">Questo interrompe il trasferimento e blocca chiunque lo stia scaricando. Rimane nella sua cronologia come Interrotto.</string>
@@ -320,10 +319,21 @@
<string name="offer_body">%1$s vuole inviarti «%2$s».</string>
<string name="offer_accept">Ricevi</string>
<string name="offer_decline">Rifiuta</string>
<string name="saved_devices_authenticated_name">Nome remoto: %1$s</string>
<string name="saved_devices_block_confirm_body">Bloccare %1$s e rifiutare i futuri trasferimenti da dispositivi salvati?</string>
<string name="saved_devices_block_confirm_title">Bloccare il dispositivo?</string>
<string name="saved_devices_description">Invia direttamente ai dispositivi attendibili senza condividere un altro invito.</string>
<string name="saved_devices_endpoint">ID dispositivo: %1$s</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_forget_confirm_body">Dimenticare %1$s? Entrambi dovrete approvare nuovamente il salvataggio prima di un altro trasferimento diretto.</string>
<string name="saved_devices_forget_confirm_title">Dimenticare il dispositivo?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_load_failed">Impossibile caricare i dispositivi salvati.</string>
<string name="saved_devices_loading">Caricamento dei dispositivi salvati…</string>
<string name="saved_devices_more_actions">Altre azioni per %1$s</string>
<string name="saved_devices_no_pending">Nessuna richiesta di associazione richiede attenzione.</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>

View File

@@ -100,9 +100,6 @@
<string name="error_starting_up">VniDrop is nog aan het opstarten. Open de uitnodiging zo meteen opnieuw.</string>
<string name="error_storage_full">Er is onvoldoende opslagruimte om deze overdracht op te slaan. Maak ruimte vrij en probeer het opnieuw.</string>
<string name="error_transfer">De overdrachtsgegevens konden niet worden verwerkt. Vraag de afzender ze opnieuw te delen.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Naam van ontvanger</string>
<string name="field_sender_name">Naam van afzender</string>
<string name="field_transfer_name">Naam van overdracht</string>
@@ -115,6 +112,7 @@
<string name="metadata_size">Grootte</string>
<string name="metadata_status">Status</string>
<string name="nav_receive">Ontvangen</string>
<string name="nav_saved_devices">Apparaten</string>
<string name="nav_send">Versturen</string>
<string name="nav_settings">Instellingen</string>
<string name="network_title">Netwerk</string>
@@ -221,6 +219,7 @@
<string name="send_new_transfer_description">Een nieuwe overdracht aanmaken</string>
<string name="send_new_transfer_title">Nieuwe overdracht</string>
<string name="send_review_title">Overdracht controleren</string>
<string name="send_default_transfer_name">%1$d bestanden</string>
<string name="send_selected_files_count">%1$d bestanden geselecteerd</string>
<string name="send_stop_sharing">Stoppen met delen</string>
<string name="send_stop_sharing_description">Hiermee stopt de overdracht en wordt iedereen die deze op dit moment downloadt onderbroken. De overdracht blijft in uw geschiedenis staan als Gestopt.</string>
@@ -320,10 +319,21 @@
<string name="offer_body">%1$s wil je “%2$s” sturen.</string>
<string name="offer_accept">Ontvangen</string>
<string name="offer_decline">Weigeren</string>
<string name="saved_devices_authenticated_name">Externe naam: %1$s</string>
<string name="saved_devices_block_confirm_body">%1$s blokkeren en toekomstige overdrachten van opgeslagen apparaten weigeren?</string>
<string name="saved_devices_block_confirm_title">Apparaat blokkeren?</string>
<string name="saved_devices_description">Stuur rechtstreeks naar vertrouwde apparaten zonder opnieuw een uitnodiging te delen.</string>
<string name="saved_devices_endpoint">Apparaat-ID: %1$s</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_forget_confirm_body">%1$s vergeten? Jullie moeten het opslaan allebei opnieuw goedkeuren voor een volgende directe overdracht.</string>
<string name="saved_devices_forget_confirm_title">Apparaat vergeten?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_load_failed">Opgeslagen apparaten konden niet worden geladen.</string>
<string name="saved_devices_loading">Opgeslagen apparaten laden…</string>
<string name="saved_devices_more_actions">Meer acties voor %1$s</string>
<string name="saved_devices_no_pending">Er zijn geen koppelverzoeken die aandacht nodig hebben.</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>

View File

@@ -100,9 +100,6 @@
<string name="error_starting_up">VniDrop jeszcze się uruchamia. Otwórz zaproszenie ponownie za chwilę.</string>
<string name="error_storage_full">Brakuje miejsca na zapisanie tego transferu. Zwolnij miejsce i spróbuj ponownie.</string>
<string name="error_transfer">Nie udało się przetworzyć danych transferu. Poproś nadawcę o ponowne udostępnienie.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Nazwa odbiorcy</string>
<string name="field_sender_name">Nazwa nadawcy</string>
<string name="field_transfer_name">Nazwa transferu</string>
@@ -115,6 +112,7 @@
<string name="metadata_size">Rozmiar</string>
<string name="metadata_status">Status</string>
<string name="nav_receive">Odbierz</string>
<string name="nav_saved_devices">Urządzenia</string>
<string name="nav_send">Wyślij</string>
<string name="nav_settings">Ustawienia</string>
<string name="network_title">Sieć</string>
@@ -221,6 +219,7 @@
<string name="send_new_transfer_description">Utwórz nowy transfer</string>
<string name="send_new_transfer_title">Nowy transfer</string>
<string name="send_review_title">Przejrzyj transfer</string>
<string name="send_default_transfer_name">Pliki: %1$d</string>
<string name="send_selected_files_count">Wybrane pliki: %1$d</string>
<string name="send_stop_sharing">Zatrzymaj udostępnianie</string>
<string name="send_stop_sharing_description">To zatrzymuje transfer i przerywa każdego, kto go właśnie pobiera. Pozostaje w historii jako Zatrzymany.</string>
@@ -320,10 +319,21 @@
<string name="offer_body">%1$s chce wysłać Ci „%2$s”.</string>
<string name="offer_accept">Odbierz</string>
<string name="offer_decline">Odrzuć</string>
<string name="saved_devices_authenticated_name">Nazwa zdalna: %1$s</string>
<string name="saved_devices_block_confirm_body">Zablokować %1$s i odrzucać przyszłe transfery z zapisanych urządzeń?</string>
<string name="saved_devices_block_confirm_title">Zablokować urządzenie?</string>
<string name="saved_devices_description">Wysyłaj bezpośrednio do zaufanych urządzeń bez udostępniania kolejnego zaproszenia.</string>
<string name="saved_devices_endpoint">ID urządzenia: %1$s</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_forget_confirm_body">Zapomnieć %1$s? Przed kolejnym transferem bezpośrednim obie strony muszą ponownie zatwierdzić zapisanie.</string>
<string name="saved_devices_forget_confirm_title">Zapomnieć urządzenie?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_load_failed">Nie udało się wczytać zapisanych urządzeń.</string>
<string name="saved_devices_loading">Wczytywanie zapisanych urządzeń…</string>
<string name="saved_devices_more_actions">Więcej działań dla %1$s</string>
<string name="saved_devices_no_pending">Żadne prośby o sparowanie nie wymagają uwagi.</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>

View File

@@ -100,9 +100,6 @@
<string name="error_starting_up">O VniDrop ainda está a iniciar. Abra o convite novamente dentro de momentos.</string>
<string name="error_storage_full">Não existe espaço de armazenamento suficiente para guardar esta transferência. Liberte espaço e tente novamente.</string>
<string name="error_transfer">Não foi possível processar os dados da transferência. Peça ao remetente para os partilhar novamente.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Nome do destinatário</string>
<string name="field_sender_name">Nome do remetente</string>
<string name="field_transfer_name">Nome da transferência</string>
@@ -115,6 +112,7 @@
<string name="metadata_size">Tamanho</string>
<string name="metadata_status">Estado</string>
<string name="nav_receive">Receber</string>
<string name="nav_saved_devices">Dispositivos</string>
<string name="nav_send">Enviar</string>
<string name="nav_settings">Definições</string>
<string name="network_title">Rede</string>
@@ -221,6 +219,7 @@
<string name="send_new_transfer_description">Criar uma nova transferência</string>
<string name="send_new_transfer_title">Nova transferência</string>
<string name="send_review_title">Rever transferência</string>
<string name="send_default_transfer_name">%1$d ficheiros</string>
<string name="send_selected_files_count">%1$d ficheiros selecionados</string>
<string name="send_stop_sharing">Parar de partilhar</string>
<string name="send_stop_sharing_description">Isto para a transferência e interrompe quem estiver a descarregá-la. Permanece no seu histórico como Parada.</string>
@@ -320,10 +319,21 @@
<string name="offer_body">%1$s quer enviar-lhe “%2$s”.</string>
<string name="offer_accept">Receber</string>
<string name="offer_decline">Recusar</string>
<string name="saved_devices_authenticated_name">Nome remoto: %1$s</string>
<string name="saved_devices_block_confirm_body">Bloquear %1$s e rejeitar futuras transferências de dispositivos guardados?</string>
<string name="saved_devices_block_confirm_title">Bloquear dispositivo?</string>
<string name="saved_devices_description">Envie diretamente para dispositivos de confiança sem partilhar outro convite.</string>
<string name="saved_devices_endpoint">ID do dispositivo: %1$s</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_forget_confirm_body">Esquecer %1$s? Ambos terão de voltar a aprovar antes de outra transferência direta.</string>
<string name="saved_devices_forget_confirm_title">Esquecer dispositivo?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_load_failed">Não foi possível carregar os dispositivos guardados.</string>
<string name="saved_devices_loading">A carregar dispositivos guardados…</string>
<string name="saved_devices_more_actions">Mais ações para %1$s</string>
<string name="saved_devices_no_pending">Não existem pedidos de emparelhamento a aguardar atenção.</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>

View File

@@ -100,9 +100,6 @@
<string name="error_starting_up">VniDrop ещё запускается. Откройте приглашение снова через мгновение.</string>
<string name="error_storage_full">Недостаточно места для сохранения этой передачи. Освободите место и повторите попытку.</string>
<string name="error_transfer">Не удалось обработать данные передачи. Попросите отправителя поделиться ими снова.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Имя получателя</string>
<string name="field_sender_name">Имя отправителя</string>
<string name="field_transfer_name">Название передачи</string>
@@ -115,6 +112,7 @@
<string name="metadata_size">Размер</string>
<string name="metadata_status">Статус</string>
<string name="nav_receive">Получить</string>
<string name="nav_saved_devices">Устройства</string>
<string name="nav_send">Отправить</string>
<string name="nav_settings">Настройки</string>
<string name="network_title">Сеть</string>
@@ -221,6 +219,7 @@
<string name="send_new_transfer_description">Создать новую передачу</string>
<string name="send_new_transfer_title">Новая передача</string>
<string name="send_review_title">Проверить передачу</string>
<string name="send_default_transfer_name">Файлов: %1$d</string>
<string name="send_selected_files_count">Выбрано файлов: %1$d</string>
<string name="send_stop_sharing">Остановить общий доступ</string>
<string name="send_stop_sharing_description">Это остановит передачу и прервёт всех, кто сейчас её загружает. Она останется в вашей истории со статусом «Остановлена».</string>
@@ -320,10 +319,21 @@
<string name="offer_body">%1$s хочет отправить вам «%2$s».</string>
<string name="offer_accept">Получить</string>
<string name="offer_decline">Отклонить</string>
<string name="saved_devices_authenticated_name">Имя устройства: %1$s</string>
<string name="saved_devices_block_confirm_body">Заблокировать %1$s и отклонять будущие передачи с сохранённых устройств?</string>
<string name="saved_devices_block_confirm_title">Заблокировать устройство?</string>
<string name="saved_devices_description">Отправляйте напрямую доверенным устройствам без новой ссылки-приглашения.</string>
<string name="saved_devices_endpoint">ID устройства: %1$s</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_forget_confirm_body">Забыть %1$s? Перед следующей прямой передачей сохранение снова должны подтвердить обе стороны.</string>
<string name="saved_devices_forget_confirm_title">Забыть устройство?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_load_failed">Не удалось загрузить сохранённые устройства.</string>
<string name="saved_devices_loading">Загрузка сохранённых устройств…</string>
<string name="saved_devices_more_actions">Другие действия для %1$s</string>
<string name="saved_devices_no_pending">Нет запросов на сопряжение, требующих внимания.</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>

View File

@@ -100,9 +100,6 @@
<string name="error_starting_up">VniDrop is still starting. Open the invitation again in a moment.</string>
<string name="error_storage_full">There is not enough storage space to save this transfer. Free up space and try again.</string>
<string name="error_transfer">The transfer data could not be processed. Ask the sender to share it again.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Receiver name</string>
<string name="field_sender_name">Sender name</string>
<string name="field_transfer_name">Transfer name</string>
@@ -115,6 +112,7 @@
<string name="metadata_size">Size</string>
<string name="metadata_status">Status</string>
<string name="nav_receive">Receive</string>
<string name="nav_saved_devices">Devices</string>
<string name="nav_send">Send</string>
<string name="nav_settings">Settings</string>
<string name="network_title">Network</string>
@@ -221,6 +219,7 @@
<string name="send_new_transfer_description">Create a new transfer</string>
<string name="send_new_transfer_title">New transfer</string>
<string name="send_review_title">Review transfer</string>
<string name="send_default_transfer_name">%1$d files</string>
<string name="send_selected_files_count">%1$d files selected</string>
<string name="send_stop_sharing">Stop sharing</string>
<string name="send_stop_sharing_description">This stops the transfer and interrupts anyone currently downloading it. It stays in your history as Stopped.</string>
@@ -320,10 +319,21 @@
<string name="offer_body">%1$s wants to send you “%2$s”.</string>
<string name="offer_accept">Receive</string>
<string name="offer_decline">Decline</string>
<string name="saved_devices_authenticated_name">Remote name: %1$s</string>
<string name="saved_devices_block_confirm_body">Block %1$s and reject future saved-device transfers?</string>
<string name="saved_devices_block_confirm_title">Block device?</string>
<string name="saved_devices_description">Send directly to devices you trust, without sharing another invitation.</string>
<string name="saved_devices_endpoint">Device ID: %1$s</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_forget_confirm_body">Forget %1$s? You will both need to approve saving again before another direct transfer.</string>
<string name="saved_devices_forget_confirm_title">Forget device?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_load_failed">Saved devices could not be loaded.</string>
<string name="saved_devices_loading">Loading saved devices…</string>
<string name="saved_devices_more_actions">More actions for %1$s</string>
<string name="saved_devices_no_pending">No pairing requests need your attention.</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>

View File

@@ -38,11 +38,14 @@ 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.saveddevices.PairingPromptHost
import com.vnidrop.app.feature.saveddevices.SavedDevicesRoute
import com.vnidrop.app.feature.saveddevices.SavedDevicesViewModel
import com.vnidrop.app.feature.saveddevices.TargetedOfferModalHost
import com.vnidrop.app.feature.send.SendRoute
import com.vnidrop.app.feature.send.SendFloatingAction
import com.vnidrop.app.feature.send.SendViewModel
import com.vnidrop.app.feature.send.TransferDraftViewModel
import com.vnidrop.app.feature.send.TransferDraftHost
import com.vnidrop.app.feature.settings.SettingsRoute
import com.vnidrop.app.feature.settings.SettingsViewModel
import com.vnidrop.app.platform.PlatformSystemAppearance
@@ -60,10 +63,12 @@ import com.vnidrop.app.ui.theme.VniDropTheme
import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withTimeoutOrNull
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.app_starting
import com.vnidrop.app.core.rememberPickedShareSourceAdapter
@Composable
fun App(
@@ -76,6 +81,7 @@ fun App(
) {
val graphHolder = viewModel { AppGraphViewModel(dependencies) }
val graph = graphHolder.graph
val sourceAdapter = rememberPickedShareSourceAdapter()
val appViewModel = viewModel {
AppViewModel(
@@ -88,8 +94,22 @@ fun App(
val sendViewModel = viewModel {
SendViewModel(
graph.coreRepository,
dependencies.fileSystemService,
graph.preferencesRepository,
graph.filePreviewRepository,
graph.messages,
)
}
val invitationDraftViewModel = viewModel(key = "invitation-transfer-draft") {
TransferDraftViewModel(
graph.coreRepository,
sourceAdapter,
graph.filePreviewRepository,
graph.messages,
)
}
val targetedDraftViewModel = viewModel(key = "targeted-transfer-draft") {
TransferDraftViewModel(
graph.coreRepository,
sourceAdapter,
graph.filePreviewRepository,
graph.messages,
)
@@ -112,8 +132,6 @@ fun App(
val savedDevicesViewModel = viewModel {
SavedDevicesViewModel(
graph.coreRepository,
dependencies.fileSystemService,
graph.preferencesRepository,
graph.messages,
)
}
@@ -125,6 +143,9 @@ fun App(
val approvalState by graph.approvalCoordinator.state.collectAsStateWithLifecycle()
val pairingPromptState by graph.pairingPromptCoordinator.state.collectAsStateWithLifecycle()
val targetedOfferState by graph.targetedOfferCoordinator.state.collectAsStateWithLifecycle()
val username by graph.preferencesRepository.preferences
.map { it.username }
.collectAsStateWithLifecycle(initialValue = dependencies.environment.defaultUsername)
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(dependencies.externalInvitations, appViewModel, receiveViewModel) {
dependencies.externalInvitations.invitations.collect { invitation ->
@@ -212,7 +233,7 @@ fun App(
floatingAction = if (showSendAction) {
{
SendFloatingAction(
onClick = sendViewModel::openComposer,
onClick = { invitationDraftViewModel.openInvitation(username) },
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
)
}
@@ -228,10 +249,15 @@ fun App(
},
) {
when (appState.destination) {
AppDestination.Send -> SendRoute(sendViewModel, windowClass)
AppDestination.Send -> SendRoute(sendViewModel, invitationDraftViewModel, username, windowClass)
AppDestination.Receive -> ReceiveRoute(receiveViewModel, windowClass)
AppDestination.SavedDevices -> SavedDevicesRoute(
savedDevicesViewModel,
targetedDraftViewModel,
windowClass,
)
AppDestination.Settings -> ScreenScrollContainer {
SettingsRoute(settingsViewModel, savedDevicesViewModel, windowClass)
SettingsRoute(settingsViewModel, windowClass)
}
}
}
@@ -251,6 +277,7 @@ fun App(
onAccept = graph.targetedOfferCoordinator::accept,
onDecline = graph.targetedOfferCoordinator::decline,
)
TransferDraftHost(targetedDraftViewModel, windowClass, onCreated = {})
}
windowChrome?.invoke()
val startingLabel = stringResource(Res.string.app_starting)

View File

@@ -62,7 +62,6 @@ class AppGraph(
)
val pairingPromptCoordinator = PairingPromptCoordinator(
repository = coreRepository,
preferencesRepository = preferencesRepository,
messages = messages,
scope = applicationScope,
)

View File

@@ -14,14 +14,6 @@ enum class UiPlatform {
val UiPlatform.isDesktop: Boolean
get() = this != UiPlatform.Android
/** Experimental saved-devices Settings chrome for Android + Windows/Linux Compose. */
fun showsExperimentalSavedDevices(uiPlatform: UiPlatform): Boolean = when (uiPlatform) {
UiPlatform.Android,
UiPlatform.Windows,
UiPlatform.Linux -> true
UiPlatform.Desktop -> false
}
data class PlatformEnvironment(
val name: String,
val appVersion: String,

View File

@@ -197,7 +197,7 @@ interface CoreGateway {
suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null): Result<Unit>
suspend fun refresh(): Result<Unit>
// Experimental saved devices / targeted transfers
// Saved devices / targeted transfers
suspend fun listPairingEligibilities(): Result<List<PairingEligibilityModel>>
suspend fun declinePairingEligibility(peerEndpointId: String): Result<Unit>
suspend fun requestSavedDevicePairing(peerEndpointId: String): Result<Boolean>

View File

@@ -589,6 +589,7 @@ private fun ReceiverRequest.toModel(): ReceiverRequestModel = ReceiverRequestMod
private fun PairingEligibilitySummary.toModel(): PairingEligibilityModel = PairingEligibilityModel(
peerEndpointId = peerEndpointId,
remoteDisplayName = remoteDisplayName,
sessionId = sessionId,
protocolVersion = protocolVersion,
createdAt = createdAt,
@@ -638,6 +639,7 @@ private fun TargetedTransfer.toModel(): TargetedTransferModel = TargetedTransfer
senderEndpointId = senderEndpointId,
receiverEndpointId = receiverEndpointId,
manifestId = manifestId,
transferName = transferName,
fileCount = fileCount,
totalSize = totalSize,
verifiedBytes = verifiedBytes,

View File

@@ -47,31 +47,6 @@ interface FileSystemService {
fun canRevealReceiveFolder(folder: ReceiveFolder): Boolean = false
suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> =
Result.failure(UnsupportedOperationException("Revealing the receive folder is not supported"))
/** Releases only app-owned picker copies; implementations must never delete original user sources. */
suspend fun discardPickedFiles(files: List<PickedShareFile>) = Unit
suspend fun sharePickedFile(
repository: CoreGateway,
file: PickedShareFile,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> = sharePickedFiles(repository, listOf(file), transferName, senderName, accessPolicy)
suspend fun sharePickedFiles(
repository: CoreGateway,
files: List<PickedShareFile>,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share>
/** Builds platform share sources and creates a targeted transfer to a saved device. */
suspend fun createTargetedTransferFromPickedFiles(
repository: CoreGateway,
receiverEndpointId: String,
files: List<PickedShareFile>,
transferName: String?,
): Result<TargetedTransferModel>
}
@Composable

View File

@@ -0,0 +1,17 @@
package com.vnidrop.app.core
import androidx.compose.runtime.Composable
internal interface PickedShareSourceAdapter {
/** Keeps platform descriptors and leases valid until [operation] returns. */
suspend fun <T> withShareSources(
files: List<PickedShareFile>,
operation: suspend (List<uniffi.vnidrop.ShareSource>) -> T,
): Result<T>
/** Releases only app-owned picker copies; implementations must never delete original user sources. */
suspend fun discardPickedFiles(files: List<PickedShareFile>) = Unit
}
@Composable
internal expect fun rememberPickedShareSourceAdapter(): PickedShareSourceAdapter

View File

@@ -1,9 +1,9 @@
package com.vnidrop.app.core
/**
* App-facing models for experimental saved devices and targeted transfers.
* Maps UniFFI types; features must not depend on `uniffi.vnidrop` for these flows
* except share sources / output sinks already used by invitation receive.
* App-facing models for saved devices and targeted transfers. Maps UniFFI types;
* features must not depend on `uniffi.vnidrop` for these flows except share
* sources / output sinks already used by invitation receive.
*/
data class SavedDeviceModel(
@@ -33,6 +33,7 @@ data class DeviceRelationshipModel(
data class PairingEligibilityModel(
val peerEndpointId: String,
val remoteDisplayName: String?,
val sessionId: String,
val protocolVersion: UShort,
val createdAt: Long,
@@ -72,6 +73,7 @@ data class TargetedTransferModel(
val senderEndpointId: String,
val receiverEndpointId: String,
val manifestId: String,
val transferName: String,
val fileCount: ULong,
val totalSize: ULong,
val verifiedBytes: ULong,

View File

@@ -3,14 +3,11 @@ package com.vnidrop.app.feature.saveddevices
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.DeviceRelationshipStateModel
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessageController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
@@ -18,14 +15,13 @@ import kotlinx.coroutines.launch
sealed interface PairingPrompt {
/** Local eligibility after a completed invitation transfer — user may remember the peer. */
data class Eligibility(val peerEndpointId: String) : PairingPrompt
data class Eligibility(val peerEndpointId: String, val remoteDisplayName: String?) : PairingPrompt
/** Peer requested pairing; user may accept or decline mutual consent. */
data class IncomingRequest(val peerEndpointId: String) : PairingPrompt
data class IncomingRequest(val peerEndpointId: String, val remoteDisplayName: String?) : PairingPrompt
}
data class PairingPromptState(
val enabled: Boolean = false,
val prompt: PairingPrompt? = null,
val busy: Boolean = false,
)
@@ -36,7 +32,6 @@ data class PairingPromptState(
*/
class PairingPromptCoordinator(
private val repository: CoreGateway,
private val preferencesRepository: PreferencesRepository,
private val messages: UiMessageController,
private val scope: CoroutineScope,
) {
@@ -48,26 +43,17 @@ class PairingPromptCoordinator(
init {
scope.launch {
// Preferences can emit before AppViewModel finishes core initialize.
// Hitting the gateway then surfaces "Initialize the core first" snackbars.
combine(
preferencesRepository.preferences.map { it.experimentalSavedDevicesEnabled },
repository.state.map { it.isInitialized },
) { enabled, initialized -> enabled to initialized }
repository.state.map { it.isInitialized }
.distinctUntilChanged()
.collectLatest { (enabled, initialized) ->
_state.update { it.copy(enabled = enabled) }
when {
enabled && initialized -> refresh()
!enabled -> _state.update { it.copy(prompt = null, busy = false) }
}
.collect { initialized ->
if (initialized) refresh()
}
}
scope.launch {
repository.signals.collect { signal ->
when (signal) {
CoreSignal.PairingChanged -> {
if (_state.value.enabled && repository.state.value.isInitialized) refresh()
if (repository.state.value.isInitialized) refresh()
}
is CoreSignal.ApprovalChanged,
is CoreSignal.ReceiverHistoryChanged,
@@ -124,15 +110,27 @@ class PairingPromptCoordinator(
}
private suspend fun refresh() {
if (!_state.value.enabled || _state.value.busy) return
if (_state.value.busy) return
val relationships = repository.listDeviceRelationships().getOrElse {
messages.error(it)
return
}
val incoming = relationships.firstOrNull { it.state == DeviceRelationshipStateModel.PendingIncoming }
if (incoming != null) {
val remoteDisplayName = repository.listPairingEligibilities()
.getOrElse {
messages.error(it)
emptyList()
}
.firstOrNull { eligibility -> eligibility.peerEndpointId == incoming.remoteEndpointId }
?.remoteDisplayName
_state.update {
it.copy(prompt = PairingPrompt.IncomingRequest(incoming.remoteEndpointId))
it.copy(
prompt = PairingPrompt.IncomingRequest(
incoming.remoteEndpointId,
remoteDisplayName,
),
)
}
return
}
@@ -142,7 +140,11 @@ class PairingPromptCoordinator(
}
val eligibility = eligibilities.firstOrNull { it.peerEndpointId !in dismissedEligibility }
_state.update {
it.copy(prompt = eligibility?.let { row -> PairingPrompt.Eligibility(row.peerEndpointId) })
it.copy(
prompt = eligibility?.let { row ->
PairingPrompt.Eligibility(row.peerEndpointId, row.remoteDisplayName)
},
)
}
}
}

View File

@@ -44,10 +44,10 @@ fun PairingPromptHost(
onDecline: () -> Unit,
onDismiss: () -> Unit,
) {
if (!state.enabled) return
val prompt = state.prompt ?: return
val colors = LocalVniDropColors.current
val deviceLabel = shortDeviceLabel(prompt.peerEndpointId())
val deviceLabel = prompt.remoteDisplayName()?.takeIf(String::isNotBlank)
?: shortDeviceLabel(prompt.peerEndpointId())
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
@@ -152,5 +152,10 @@ private fun PairingPrompt.peerEndpointId(): String = when (this) {
is PairingPrompt.IncomingRequest -> peerEndpointId
}
private fun PairingPrompt.remoteDisplayName(): String? = when (this) {
is PairingPrompt.Eligibility -> remoteDisplayName
is PairingPrompt.IncomingRequest -> remoteDisplayName
}
private fun shortDeviceLabel(endpointId: String): String =
if (endpointId.length <= 12) endpointId else endpointId.take(8) + ""

View File

@@ -1,250 +0,0 @@
package com.vnidrop.app.feature.saveddevices
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.DeviceRelationshipStateModel
import com.vnidrop.app.core.SavedDeviceModel
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.SecondaryButton
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_cancel
import vnidrop.shared.generated.resources.saved_devices_accept_pairing_action
import vnidrop.shared.generated.resources.saved_devices_block_action
import vnidrop.shared.generated.resources.saved_devices_decline_action
import vnidrop.shared.generated.resources.saved_devices_eligibility_title
import vnidrop.shared.generated.resources.saved_devices_empty
import vnidrop.shared.generated.resources.saved_devices_forget_action
import vnidrop.shared.generated.resources.saved_devices_label_action
import vnidrop.shared.generated.resources.saved_devices_label_clear
import vnidrop.shared.generated.resources.saved_devices_label_placeholder
import vnidrop.shared.generated.resources.saved_devices_label_save
import vnidrop.shared.generated.resources.saved_devices_label_title
import vnidrop.shared.generated.resources.saved_devices_list_title
import vnidrop.shared.generated.resources.saved_devices_pending_incoming
import vnidrop.shared.generated.resources.saved_devices_pending_outgoing
import vnidrop.shared.generated.resources.saved_devices_pending_title
import vnidrop.shared.generated.resources.saved_devices_remember_action
import vnidrop.shared.generated.resources.saved_devices_send_action
import vnidrop.shared.generated.resources.saved_devices_unnamed
@Composable
fun SavedDevicesPanel(
state: SavedDevicesState,
onRememberEligible: (String) -> Unit,
onDeclineEligible: (String) -> Unit,
onAcceptIncoming: (String) -> Unit,
onDeclineIncoming: (String) -> Unit,
onSend: (String) -> Unit,
onOpenLabel: (String) -> Unit,
onForget: (String) -> Unit,
onBlock: (String) -> Unit,
onLabelDraftChanged: (String) -> Unit,
onSaveLabel: () -> Unit,
onClearLabel: () -> Unit,
onDismissLabel: () -> Unit,
) {
if (!state.enabled) return
val colors = LocalVniDropColors.current
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
if (state.eligibilities.isNotEmpty()) {
Text(
stringResource(Res.string.saved_devices_eligibility_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
PanelGroup {
state.eligibilities.forEach { eligibility ->
val busy = eligibility.peerEndpointId in state.busyPeerIds
Column(
Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(shortEndpoint(eligibility.peerEndpointId), style = MaterialTheme.typography.bodyLarge)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PrimaryButton(
stringResource(Res.string.saved_devices_remember_action),
{ onRememberEligible(eligibility.peerEndpointId) },
enabled = !busy,
)
SecondaryButton(
stringResource(Res.string.saved_devices_decline_action),
{ onDeclineEligible(eligibility.peerEndpointId) },
enabled = !busy,
)
}
}
}
}
}
if (state.pendingRelationships.isNotEmpty()) {
Text(
stringResource(Res.string.saved_devices_pending_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
PanelGroup {
state.pendingRelationships.forEach { relationship ->
val busy = relationship.remoteEndpointId in state.busyPeerIds
Column(
Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(shortEndpoint(relationship.remoteEndpointId), style = MaterialTheme.typography.bodyLarge)
Text(
stringResource(
when (relationship.state) {
DeviceRelationshipStateModel.PendingIncoming ->
Res.string.saved_devices_pending_incoming
else -> Res.string.saved_devices_pending_outgoing
},
),
style = MaterialTheme.typography.bodySmall,
color = colors.foregroundLighter,
)
if (relationship.state == DeviceRelationshipStateModel.PendingIncoming) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PrimaryButton(
stringResource(Res.string.saved_devices_accept_pairing_action),
{ onAcceptIncoming(relationship.remoteEndpointId) },
enabled = !busy,
)
SecondaryButton(
stringResource(Res.string.saved_devices_decline_action),
{ onDeclineIncoming(relationship.remoteEndpointId) },
enabled = !busy,
)
}
}
}
}
}
}
Text(
stringResource(Res.string.saved_devices_list_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
if (state.savedDevices.isEmpty() && state.eligibilities.isEmpty() && state.pendingRelationships.isEmpty()) {
Text(
stringResource(Res.string.saved_devices_empty),
style = MaterialTheme.typography.bodyMedium,
color = colors.foregroundLight,
)
} else if (state.savedDevices.isNotEmpty()) {
PanelGroup {
state.savedDevices.forEach { device ->
SavedDeviceRow(
device = device,
busy = device.endpointId in state.busyPeerIds || state.isSending,
onSend = { onSend(device.endpointId) },
onLabel = { onOpenLabel(device.endpointId) },
onForget = { onForget(device.endpointId) },
onBlock = { onBlock(device.endpointId) },
)
}
}
}
}
val labelingPeerId = state.labelingPeerId
if (labelingPeerId != null) {
AlertDialog(
onDismissRequest = onDismissLabel,
title = { Text(stringResource(Res.string.saved_devices_label_title)) },
text = {
OutlinedTextField(
value = state.labelDraft,
onValueChange = onLabelDraftChanged,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text(stringResource(Res.string.saved_devices_label_placeholder)) },
)
},
confirmButton = {
TextButton(onClick = onSaveLabel) {
Text(stringResource(Res.string.saved_devices_label_save))
}
},
dismissButton = {
Row {
TextButton(onClick = onClearLabel) {
Text(stringResource(Res.string.saved_devices_label_clear))
}
TextButton(onClick = onDismissLabel) {
Text(stringResource(Res.string.button_cancel))
}
}
},
)
}
}
@Composable
private fun PanelGroup(content: @Composable ColumnScope.() -> Unit) {
val colors = LocalVniDropColors.current
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200),
border = BorderStroke(1.dp, colors.borderDefault.copy(alpha = 0.72f)),
content = { Column(content = content) },
)
}
@Composable
private fun SavedDeviceRow(
device: SavedDeviceModel,
busy: Boolean,
onSend: () -> Unit,
onLabel: () -> Unit,
onForget: () -> Unit,
onBlock: () -> Unit,
) {
val colors = LocalVniDropColors.current
val title = device.localLabel?.takeIf { it.isNotBlank() }
?: device.remoteDisplayName?.takeIf { it.isNotBlank() }
?: stringResource(Res.string.saved_devices_unnamed)
Column(
Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold)
Text(
shortEndpoint(device.endpointId),
style = MaterialTheme.typography.bodySmall,
color = colors.foregroundLighter,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PrimaryButton(stringResource(Res.string.saved_devices_send_action), onSend, enabled = !busy)
SecondaryButton(stringResource(Res.string.saved_devices_label_action), onLabel, enabled = !busy)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SecondaryButton(stringResource(Res.string.saved_devices_forget_action), onForget, enabled = !busy)
SecondaryButton(stringResource(Res.string.saved_devices_block_action), onBlock, enabled = !busy)
}
}
}
private fun shortEndpoint(endpointId: String): String =
if (endpointId.length <= 16) endpointId else endpointId.take(12) + ""

View File

@@ -0,0 +1,40 @@
package com.vnidrop.app.feature.saveddevices
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vnidrop.app.feature.send.TransferDraftViewModel
import com.vnidrop.app.ui.state.WindowClass
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.saved_devices_unnamed
@Composable
internal fun SavedDevicesRoute(
viewModel: SavedDevicesViewModel,
targetedDraftViewModel: TransferDraftViewModel,
windowClass: WindowClass,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val unnamedDeviceName = stringResource(Res.string.saved_devices_unnamed)
SavedDevicesScreen(
state = state,
windowClass = windowClass,
onRetry = viewModel::retry,
onRememberEligible = viewModel::rememberEligible,
onDeclineEligible = viewModel::declineEligible,
onAcceptIncoming = viewModel::acceptIncoming,
onDeclineIncoming = viewModel::declineIncoming,
onSend = { peerEndpointId ->
state.savedDevices.firstOrNull { it.endpointId == peerEndpointId }
?.let { targetedDraftViewModel.openTargeted(it, unnamedDeviceName) }
},
onOpenLabel = viewModel::openLabelEditor,
onForget = viewModel::forget,
onBlock = viewModel::block,
onLabelDraftChanged = viewModel::setLabelDraft,
onSaveLabel = viewModel::saveLabel,
onClearLabel = viewModel::clearLabel,
onDismissLabel = viewModel::dismissLabelEditor,
)
}

View File

@@ -0,0 +1,666 @@
package com.vnidrop.app.feature.saveddevices
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.DeviceRelationshipModel
import com.vnidrop.app.core.DeviceRelationshipStateModel
import com.vnidrop.app.core.PairingEligibilityModel
import com.vnidrop.app.core.SavedDeviceModel
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.icons.AppIcon
import com.vnidrop.app.ui.icons.PlatformIcon
import com.vnidrop.app.ui.state.WindowClass
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_cancel
import vnidrop.shared.generated.resources.button_retry
import vnidrop.shared.generated.resources.saved_devices_accept_pairing_action
import vnidrop.shared.generated.resources.saved_devices_authenticated_name
import vnidrop.shared.generated.resources.saved_devices_block_action
import vnidrop.shared.generated.resources.saved_devices_block_confirm_body
import vnidrop.shared.generated.resources.saved_devices_block_confirm_title
import vnidrop.shared.generated.resources.saved_devices_decline_action
import vnidrop.shared.generated.resources.saved_devices_description
import vnidrop.shared.generated.resources.saved_devices_eligibility_title
import vnidrop.shared.generated.resources.saved_devices_empty
import vnidrop.shared.generated.resources.saved_devices_endpoint
import vnidrop.shared.generated.resources.saved_devices_forget_action
import vnidrop.shared.generated.resources.saved_devices_forget_confirm_body
import vnidrop.shared.generated.resources.saved_devices_forget_confirm_title
import vnidrop.shared.generated.resources.saved_devices_label_action
import vnidrop.shared.generated.resources.saved_devices_label_clear
import vnidrop.shared.generated.resources.saved_devices_label_placeholder
import vnidrop.shared.generated.resources.saved_devices_label_save
import vnidrop.shared.generated.resources.saved_devices_label_title
import vnidrop.shared.generated.resources.saved_devices_list_title
import vnidrop.shared.generated.resources.saved_devices_load_failed
import vnidrop.shared.generated.resources.saved_devices_loading
import vnidrop.shared.generated.resources.saved_devices_more_actions
import vnidrop.shared.generated.resources.saved_devices_no_pending
import vnidrop.shared.generated.resources.saved_devices_pending_incoming
import vnidrop.shared.generated.resources.saved_devices_pending_outgoing
import vnidrop.shared.generated.resources.saved_devices_pending_title
import vnidrop.shared.generated.resources.saved_devices_remember_action
import vnidrop.shared.generated.resources.saved_devices_send_action
import vnidrop.shared.generated.resources.saved_devices_unnamed
@Composable
internal fun SavedDevicesScreen(
state: SavedDevicesState,
windowClass: WindowClass,
modifier: Modifier = Modifier,
onRetry: () -> Unit,
onRememberEligible: (String) -> Unit,
onDeclineEligible: (String) -> Unit,
onAcceptIncoming: (String) -> Unit,
onDeclineIncoming: (String) -> Unit,
onSend: (String) -> Unit,
onOpenLabel: (String) -> Unit,
onForget: (String) -> Unit,
onBlock: (String) -> Unit,
onLabelDraftChanged: (String) -> Unit,
onSaveLabel: () -> Unit,
onClearLabel: () -> Unit,
onDismissLabel: () -> Unit,
) {
val hasContent = state.eligibilities.isNotEmpty() || state.pendingRelationships.isNotEmpty() || state.savedDevices.isNotEmpty()
Column(
modifier = modifier
.fillMaxSize()
.statusBarsPadding()
.padding(top = 20.dp),
) {
SavedDevicesHeader(Modifier.padding(horizontal = if (windowClass == WindowClass.Desktop) 24.dp else 16.dp))
Spacer(Modifier.height(16.dp))
when {
state.isLoading && !hasContent -> SavedDevicesLoading(Modifier.weight(1f))
state.loadFailed && !hasContent -> SavedDevicesLoadFailure(onRetry, Modifier.weight(1f))
windowClass == WindowClass.Desktop -> DesktopSavedDevicesContent(
state = state,
onRetry = onRetry,
onRememberEligible = onRememberEligible,
onDeclineEligible = onDeclineEligible,
onAcceptIncoming = onAcceptIncoming,
onDeclineIncoming = onDeclineIncoming,
onSend = onSend,
onOpenLabel = onOpenLabel,
onForget = onForget,
onBlock = onBlock,
)
else -> CompactSavedDevicesContent(
state = state,
onRetry = onRetry,
onRememberEligible = onRememberEligible,
onDeclineEligible = onDeclineEligible,
onAcceptIncoming = onAcceptIncoming,
onDeclineIncoming = onDeclineIncoming,
onSend = onSend,
onOpenLabel = onOpenLabel,
onForget = onForget,
onBlock = onBlock,
)
}
}
SavedDeviceLabelDialog(
visible = state.labelingPeerId != null,
label = state.labelDraft,
onLabelChanged = onLabelDraftChanged,
onSave = onSaveLabel,
onClear = onClearLabel,
onDismiss = onDismissLabel,
)
}
@Composable
private fun SavedDevicesHeader(modifier: Modifier = Modifier) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = stringResource(Res.string.saved_devices_list_title),
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.semantics { heading() },
)
Text(
text = stringResource(Res.string.saved_devices_description),
style = MaterialTheme.typography.bodyLarge,
color = LocalVniDropColors.current.foregroundLight,
)
}
}
@Composable
private fun CompactSavedDevicesContent(
state: SavedDevicesState,
onRetry: () -> Unit,
onRememberEligible: (String) -> Unit,
onDeclineEligible: (String) -> Unit,
onAcceptIncoming: (String) -> Unit,
onDeclineIncoming: (String) -> Unit,
onSend: (String) -> Unit,
onOpenLabel: (String) -> Unit,
onForget: (String) -> Unit,
onBlock: (String) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (state.isLoading) item(key = "loading") { LinearProgressIndicator(Modifier.fillMaxWidth()) }
if (state.loadFailed) item(key = "load-failed") { InlineLoadFailure(onRetry) }
pairingItems(
state = state,
onRememberEligible = onRememberEligible,
onDeclineEligible = onDeclineEligible,
onAcceptIncoming = onAcceptIncoming,
onDeclineIncoming = onDeclineIncoming,
)
item(key = "saved-title") { SectionTitle(stringResource(Res.string.saved_devices_list_title)) }
if (state.savedDevices.isEmpty()) {
item(key = "empty") { SavedDevicesEmptyCard() }
} else {
items(state.savedDevices, key = { "saved-${it.endpointId}" }) { device ->
SavedDeviceCard(
device = device,
busy = device.endpointId in state.busyPeerIds,
onSend = { onSend(device.endpointId) },
onLabel = { onOpenLabel(device.endpointId) },
onForget = { onForget(device.endpointId) },
onBlock = { onBlock(device.endpointId) },
)
}
}
}
}
@Composable
private fun DesktopSavedDevicesContent(
state: SavedDevicesState,
onRetry: () -> Unit,
onRememberEligible: (String) -> Unit,
onDeclineEligible: (String) -> Unit,
onAcceptIncoming: (String) -> Unit,
onDeclineIncoming: (String) -> Unit,
onSend: (String) -> Unit,
onOpenLabel: (String) -> Unit,
onForget: (String) -> Unit,
onBlock: (String) -> Unit,
) {
Row(
modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp),
) {
LazyColumn(
modifier = Modifier.weight(0.8f).fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp),
) {
if (state.isLoading) item(key = "loading") { LinearProgressIndicator(Modifier.fillMaxWidth()) }
if (state.loadFailed) item(key = "load-failed") { InlineLoadFailure(onRetry) }
pairingItems(
state = state,
onRememberEligible = onRememberEligible,
onDeclineEligible = onDeclineEligible,
onAcceptIncoming = onAcceptIncoming,
onDeclineIncoming = onDeclineIncoming,
)
if (state.eligibilities.isEmpty() && state.pendingRelationships.isEmpty()) {
item(key = "no-attention") {
DesktopStatusCard()
}
}
}
LazyColumn(
modifier = Modifier.weight(1.2f).fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp),
) {
item(key = "saved-title") { SectionTitle(stringResource(Res.string.saved_devices_list_title)) }
if (state.savedDevices.isEmpty()) {
item(key = "empty") { SavedDevicesEmptyCard() }
} else {
items(state.savedDevices, key = { "saved-${it.endpointId}" }) { device ->
SavedDeviceCard(
device = device,
busy = device.endpointId in state.busyPeerIds,
onSend = { onSend(device.endpointId) },
onLabel = { onOpenLabel(device.endpointId) },
onForget = { onForget(device.endpointId) },
onBlock = { onBlock(device.endpointId) },
)
}
}
}
}
}
private fun androidx.compose.foundation.lazy.LazyListScope.pairingItems(
state: SavedDevicesState,
onRememberEligible: (String) -> Unit,
onDeclineEligible: (String) -> Unit,
onAcceptIncoming: (String) -> Unit,
onDeclineIncoming: (String) -> Unit,
) {
if (state.eligibilities.isNotEmpty()) {
item(key = "eligibility-title") {
SectionTitle(stringResource(Res.string.saved_devices_eligibility_title))
}
items(state.eligibilities, key = { "eligibility-${it.peerEndpointId}" }) { eligibility ->
EligibilityCard(
eligibility = eligibility,
busy = eligibility.peerEndpointId in state.busyPeerIds,
onRemember = { onRememberEligible(eligibility.peerEndpointId) },
onDecline = { onDeclineEligible(eligibility.peerEndpointId) },
)
}
}
if (state.pendingRelationships.isNotEmpty()) {
item(key = "pending-title") {
SectionTitle(stringResource(Res.string.saved_devices_pending_title))
}
items(state.pendingRelationships, key = { "pending-${it.remoteEndpointId}" }) { relationship ->
PendingPairingCard(
relationship = relationship,
remoteDisplayName = state.eligibilities
.firstOrNull { it.peerEndpointId == relationship.remoteEndpointId }
?.remoteDisplayName,
busy = relationship.remoteEndpointId in state.busyPeerIds,
onAccept = { onAcceptIncoming(relationship.remoteEndpointId) },
onDecline = { onDeclineIncoming(relationship.remoteEndpointId) },
)
}
}
}
@Composable
private fun SectionTitle(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 4.dp).semantics { heading() },
)
}
@Composable
private fun EligibilityCard(
eligibility: PairingEligibilityModel,
busy: Boolean,
onRemember: () -> Unit,
onDecline: () -> Unit,
) {
PairingCard(
name = eligibility.remoteDisplayName,
endpointId = eligibility.peerEndpointId,
status = stringResource(Res.string.saved_devices_eligibility_title),
busy = busy,
actions = {
PrimaryButton(stringResource(Res.string.saved_devices_remember_action), onRemember, enabled = !busy)
SecondaryButton(stringResource(Res.string.saved_devices_decline_action), onDecline, enabled = !busy)
},
)
}
@Composable
private fun PendingPairingCard(
relationship: DeviceRelationshipModel,
remoteDisplayName: String?,
busy: Boolean,
onAccept: () -> Unit,
onDecline: () -> Unit,
) {
PairingCard(
name = remoteDisplayName,
endpointId = relationship.remoteEndpointId,
status = stringResource(
when (relationship.state) {
DeviceRelationshipStateModel.PendingIncoming -> Res.string.saved_devices_pending_incoming
else -> Res.string.saved_devices_pending_outgoing
},
),
busy = busy,
actions = if (relationship.state == DeviceRelationshipStateModel.PendingIncoming) {
{
PrimaryButton(stringResource(Res.string.saved_devices_accept_pairing_action), onAccept, enabled = !busy)
SecondaryButton(stringResource(Res.string.saved_devices_decline_action), onDecline, enabled = !busy)
}
} else {
null
},
)
}
@Composable
private fun PairingCard(
name: String?,
endpointId: String,
status: String,
busy: Boolean,
actions: (@Composable RowScope.() -> Unit)?,
) {
val colors = LocalVniDropColors.current
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200),
border = BorderStroke(1.dp, colors.borderDefault.copy(alpha = 0.72f)),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
PlatformIcon(AppIcon.Shield, contentDescription = null, tint = colors.brandLink, modifier = Modifier.size(24.dp))
Column(Modifier.weight(1f)) {
Text(
name?.takeIf(String::isNotBlank) ?: stringResource(Res.string.saved_devices_unnamed),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(status, style = MaterialTheme.typography.bodyMedium, color = colors.foregroundLight)
}
if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
}
DiagnosticEndpoint(endpointId)
if (actions != null) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), content = actions)
}
}
}
}
private enum class DeviceDestructiveAction { Forget, Block }
@Composable
private fun SavedDeviceCard(
device: SavedDeviceModel,
busy: Boolean,
onSend: () -> Unit,
onLabel: () -> Unit,
onForget: () -> Unit,
onBlock: () -> Unit,
) {
val colors = LocalVniDropColors.current
val title = device.displayName()
var menuExpanded by remember(device.endpointId) { mutableStateOf(false) }
var pendingAction by remember(device.endpointId) { mutableStateOf<DeviceDestructiveAction?>(null) }
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200),
border = BorderStroke(1.dp, colors.borderDefault.copy(alpha = 0.72f)),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(contentAlignment = Alignment.Center) {
Card(
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundSelection),
) {
PlatformIcon(
AppIcon.ShieldCheck,
contentDescription = null,
tint = colors.brandLink,
modifier = Modifier.padding(10.dp).size(24.dp),
)
}
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
device.remoteDisplayName
?.takeIf { device.localLabel?.isNotBlank() == true && it.isNotBlank() }
?.let { authenticatedName ->
Text(
stringResource(Res.string.saved_devices_authenticated_name, authenticatedName),
style = MaterialTheme.typography.bodySmall,
color = colors.foregroundLight,
)
}
}
if (busy) {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Box {
val moreLabel = stringResource(Res.string.saved_devices_more_actions, title)
IconButton(onClick = { menuExpanded = true }) {
PlatformIcon(AppIcon.MoreVertical, contentDescription = moreLabel)
}
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem(
text = { Text(stringResource(Res.string.saved_devices_label_action)) },
onClick = { menuExpanded = false; onLabel() },
leadingIcon = { PlatformIcon(AppIcon.User, contentDescription = null) },
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.saved_devices_forget_action)) },
onClick = { menuExpanded = false; pendingAction = DeviceDestructiveAction.Forget },
leadingIcon = { PlatformIcon(AppIcon.UserOff, contentDescription = null) },
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.saved_devices_block_action)) },
onClick = { menuExpanded = false; pendingAction = DeviceDestructiveAction.Block },
leadingIcon = { PlatformIcon(AppIcon.Lock, contentDescription = null) },
)
}
}
}
}
DiagnosticEndpoint(device.endpointId)
PrimaryButton(
text = stringResource(Res.string.saved_devices_send_action),
onClick = onSend,
modifier = Modifier.fillMaxWidth(),
enabled = !busy,
leadingIcon = { PlatformIcon(AppIcon.Send, contentDescription = null, modifier = Modifier.size(18.dp)) },
)
}
}
pendingAction?.let { action ->
val isBlock = action == DeviceDestructiveAction.Block
AlertDialog(
onDismissRequest = { pendingAction = null },
title = {
Text(stringResource(if (isBlock) Res.string.saved_devices_block_confirm_title else Res.string.saved_devices_forget_confirm_title))
},
text = {
Text(
stringResource(
if (isBlock) Res.string.saved_devices_block_confirm_body else Res.string.saved_devices_forget_confirm_body,
title,
),
)
},
confirmButton = {
TextButton(
onClick = {
pendingAction = null
if (isBlock) onBlock() else onForget()
},
) {
Text(stringResource(if (isBlock) Res.string.saved_devices_block_action else Res.string.saved_devices_forget_action))
}
},
dismissButton = {
TextButton(onClick = { pendingAction = null }) {
Text(stringResource(Res.string.button_cancel))
}
},
)
}
}
@Composable
private fun DiagnosticEndpoint(endpointId: String) {
Text(
text = stringResource(Res.string.saved_devices_endpoint, shortEndpoint(endpointId)),
style = MaterialTheme.typography.bodySmall,
color = LocalVniDropColors.current.foregroundLighter,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun SavedDevicesEmptyCard() {
val colors = LocalVniDropColors.current
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
PlatformIcon(AppIcon.ShieldCheck, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(32.dp))
Text(
stringResource(Res.string.saved_devices_empty),
style = MaterialTheme.typography.bodyLarge,
color = colors.foregroundLight,
)
}
}
}
@Composable
private fun DesktopStatusCard() {
val colors = LocalVniDropColors.current
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200),
shape = RoundedCornerShape(16.dp),
) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
PlatformIcon(AppIcon.Check, contentDescription = null, tint = colors.brandLink, modifier = Modifier.size(24.dp))
Text(stringResource(Res.string.saved_devices_pending_title), style = MaterialTheme.typography.titleMedium)
Text(stringResource(Res.string.saved_devices_no_pending), color = colors.foregroundLight)
}
}
}
@Composable
private fun SavedDevicesLoading(modifier: Modifier = Modifier) {
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
CircularProgressIndicator()
Text(stringResource(Res.string.saved_devices_loading), color = LocalVniDropColors.current.foregroundLight)
}
}
}
@Composable
private fun SavedDevicesLoadFailure(onRetry: () -> Unit, modifier: Modifier = Modifier) {
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
PlatformIcon(AppIcon.CloudOff, contentDescription = null, modifier = Modifier.size(32.dp))
Text(stringResource(Res.string.saved_devices_load_failed), color = LocalVniDropColors.current.foregroundLight)
SecondaryButton(stringResource(Res.string.button_retry), onRetry)
}
}
}
@Composable
private fun InlineLoadFailure(onRetry: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = LocalVniDropColors.current.backgroundSurface200),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(stringResource(Res.string.saved_devices_load_failed), Modifier.weight(1f))
TextButton(onClick = onRetry) { Text(stringResource(Res.string.button_retry)) }
}
}
}
@Composable
private fun SavedDeviceLabelDialog(
visible: Boolean,
label: String,
onLabelChanged: (String) -> Unit,
onSave: () -> Unit,
onClear: () -> Unit,
onDismiss: () -> Unit,
) {
if (!visible) return
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(Res.string.saved_devices_label_title)) },
text = {
OutlinedTextField(
value = label,
onValueChange = onLabelChanged,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text(stringResource(Res.string.saved_devices_label_placeholder)) },
)
},
confirmButton = {
TextButton(onClick = onSave) { Text(stringResource(Res.string.saved_devices_label_save)) }
},
dismissButton = {
Row {
TextButton(onClick = onClear) { Text(stringResource(Res.string.saved_devices_label_clear)) }
TextButton(onClick = onDismiss) { Text(stringResource(Res.string.button_cancel)) }
}
},
)
}
@Composable
private fun SavedDeviceModel.displayName(): String = localLabel?.takeIf(String::isNotBlank)
?: remoteDisplayName?.takeIf(String::isNotBlank)
?: stringResource(Res.string.saved_devices_unnamed)
private fun shortEndpoint(endpointId: String): String =
if (endpointId.length <= 20) endpointId else endpointId.take(16) + ""

View File

@@ -6,73 +6,48 @@ import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.DeviceRelationshipModel
import com.vnidrop.app.core.DeviceRelationshipStateModel
import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.PairingEligibilityModel
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.SavedDeviceModel
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.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.saved_devices_blocked
import vnidrop.shared.generated.resources.saved_devices_forgotten
import vnidrop.shared.generated.resources.saved_devices_labeled
import vnidrop.shared.generated.resources.saved_devices_send_started
data class SavedDevicesState(
val enabled: Boolean = false,
val isLoading: Boolean = true,
val loadFailed: Boolean = false,
val eligibilities: List<PairingEligibilityModel> = emptyList(),
val pendingRelationships: List<DeviceRelationshipModel> = emptyList(),
val savedDevices: List<SavedDeviceModel> = emptyList(),
val busyPeerIds: Set<String> = emptySet(),
val labelingPeerId: String? = null,
val labelDraft: String = "",
val sendTargetPeerId: String? = null,
val isSending: Boolean = false,
)
sealed interface SavedDevicesEffect {
data object OpenFilePicker : SavedDevicesEffect
}
class SavedDevicesViewModel(
private val repository: CoreGateway,
private val fileSystemService: FileSystemService,
preferencesRepository: PreferencesRepository,
private val messages: UiMessageController,
) : ViewModel() {
private val _state = MutableStateFlow(SavedDevicesState())
val state: StateFlow<SavedDevicesState> = _state.asStateFlow()
private val effects = Channel<SavedDevicesEffect>(Channel.BUFFERED)
val effectFlow = effects.receiveAsFlow()
init {
viewModelScope.launch {
combine(
preferencesRepository.preferences.map { it.experimentalSavedDevicesEnabled },
repository.state.map { it.isInitialized },
) { enabled, initialized -> enabled to initialized }
repository.state.map { it.isInitialized }
.distinctUntilChanged()
.collectLatest { (enabled, initialized) ->
_state.update { it.copy(enabled = enabled) }
when {
enabled && initialized -> refresh()
!enabled -> _state.update { SavedDevicesState(enabled = false) }
}
.collect { initialized ->
if (initialized) refresh()
}
}
viewModelScope.launch {
@@ -80,7 +55,7 @@ class SavedDevicesViewModel(
when (signal) {
CoreSignal.PairingChanged,
CoreSignal.TargetedTransferChanged -> {
if (_state.value.enabled && repository.state.value.isInitialized) refresh()
if (repository.state.value.isInitialized) refresh()
}
is CoreSignal.ApprovalChanged,
is CoreSignal.ReceiverHistoryChanged,
@@ -90,6 +65,11 @@ class SavedDevicesViewModel(
}
}
fun retry() {
if (!repository.state.value.isInitialized || _state.value.isLoading) return
viewModelScope.launch { refresh() }
}
fun rememberEligible(peerEndpointId: String) = mutatePeer(peerEndpointId) {
repository.requestSavedDevicePairing(peerEndpointId).map { }
}
@@ -154,46 +134,6 @@ class SavedDevicesViewModel(
}
}
fun startSend(peerEndpointId: String) {
if (_state.value.isSending) return
_state.update { it.copy(sendTargetPeerId = peerEndpointId) }
viewModelScope.launch { effects.send(SavedDevicesEffect.OpenFilePicker) }
}
fun onFilesPicked(files: List<PickedShareFile>) {
val peerId = _state.value.sendTargetPeerId ?: return
if (files.isEmpty() || _state.value.isSending) return
viewModelScope.launch {
_state.update { it.copy(isSending = true) }
val transferName = when {
files.size == 1 -> files.first().displayName
files.all { it.isDirectory } -> "${files.size} folders"
else -> "${files.size} files"
}
val result = fileSystemService.createTargetedTransferFromPickedFiles(
repository = repository,
receiverEndpointId = peerId,
files = files,
transferName = transferName,
)
if (result.isSuccess) fileSystemService.discardPickedFiles(files)
_state.update { it.copy(isSending = false, sendTargetPeerId = null) }
result.fold(
onSuccess = {
messages.tryShow(
UiMessage(UiText.Resource(Res.string.saved_devices_send_started), UiMessageTone.Success),
)
},
onFailure = messages::error,
)
}
}
fun onFilePickFailed(reason: String) {
_state.update { it.copy(sendTargetPeerId = null) }
messages.error(IllegalStateException(reason.ifBlank { "selection failed" }))
}
private fun mutatePeer(peerEndpointId: String, block: suspend () -> Result<*>) {
if (peerEndpointId in _state.value.busyPeerIds) return
_state.update { it.copy(busyPeerIds = it.busyPeerIds + peerEndpointId) }
@@ -207,21 +147,26 @@ class SavedDevicesViewModel(
}
private suspend fun refresh() {
if (!_state.value.enabled) return
_state.update { it.copy(isLoading = true, loadFailed = false) }
val eligibilities = repository.listPairingEligibilities().getOrElse {
_state.update { state -> state.copy(isLoading = false, loadFailed = true) }
messages.error(it)
return
}
val relationships = repository.listDeviceRelationships().getOrElse {
_state.update { state -> state.copy(isLoading = false, loadFailed = true) }
messages.error(it)
return
}
val saved = repository.listSavedDevices().getOrElse {
_state.update { state -> state.copy(isLoading = false, loadFailed = true) }
messages.error(it)
return
}
_state.update {
it.copy(
isLoading = false,
loadFailed = false,
eligibilities = eligibilities.sortedByDescending(PairingEligibilityModel::createdAt),
pendingRelationships = relationships.filter {
it.state == DeviceRelationshipStateModel.PendingIncoming ||

View File

@@ -25,12 +25,15 @@ import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.receive_completed
data class TargetedOfferState(
val enabled: Boolean = false,
val pending: List<PendingTargetedOfferModel> = emptyList(),
val senderDisplayNames: Map<String, String> = emptyMap(),
val respondingIds: Set<String> = emptySet(),
) {
val current: PendingTargetedOfferModel?
get() = pending.firstOrNull()
val currentSenderDisplayName: String?
get() = current?.senderEndpointId?.let(senderDisplayNames::get)
}
/**
@@ -61,19 +64,14 @@ class TargetedOfferCoordinator(
.distinctUntilChanged()
.collectLatest { (preferences, initialized) ->
receiveFolder = fileSystemService.effectiveReceiveFolder(preferences.receiveFolder)
val enabled = preferences.experimentalSavedDevicesEnabled
_state.update { it.copy(enabled = enabled) }
when {
enabled && initialized -> refresh()
!enabled -> _state.update { it.copy(pending = emptyList()) }
}
if (initialized) refresh()
}
}
scope.launch {
repository.signals.collect { signal ->
when (signal) {
CoreSignal.TargetedTransferChanged -> {
if (_state.value.enabled && repository.state.value.isInitialized) refresh()
if (repository.state.value.isInitialized) refresh()
}
CoreSignal.PairingChanged,
is CoreSignal.ApprovalChanged,
@@ -125,14 +123,24 @@ class TargetedOfferCoordinator(
}
private suspend fun refresh() {
if (!_state.value.enabled) return
repository.listPendingTargetedOffers().fold(
onSuccess = { offers ->
_state.update {
it.copy(pending = offers.sortedBy(PendingTargetedOfferModel::receivedAt))
}
},
onFailure = messages::error,
)
val offers = repository.listPendingTargetedOffers().getOrElse {
messages.error(it)
return
}
val savedDevices = repository.listSavedDevices().getOrElse {
messages.error(it)
return
}
_state.update {
it.copy(
pending = offers.sortedBy(PendingTargetedOfferModel::receivedAt),
senderDisplayNames = savedDevices.associate { device ->
device.endpointId to (
device.localLabel?.takeIf(String::isNotBlank)
?: device.remoteDisplayName?.takeIf(String::isNotBlank)
).orEmpty()
}.filterValues(String::isNotBlank),
)
}
}
}

View File

@@ -41,11 +41,11 @@ fun TargetedOfferModalHost(
onAccept: (String) -> Unit,
onDecline: (String) -> Unit,
) {
if (!state.enabled) return
val offer = state.current ?: return
val busy = offer.transferId in state.respondingIds
val colors = LocalVniDropColors.current
val device = shortEndpoint(offer.senderEndpointId)
val device = state.currentSenderDisplayName?.takeIf(String::isNotBlank)
?: shortEndpoint(offer.senderEndpointId)
Dialog(
onDismissRequest = {},
properties = DialogProperties(

View File

@@ -6,25 +6,23 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vnidrop.app.core.rememberShareFilePicker
import com.vnidrop.app.ui.state.WindowClass
@Composable
fun SendRoute(
internal fun SendRoute(
viewModel: SendViewModel,
draftViewModel: TransferDraftViewModel,
defaultSenderName: String,
windowClass: WindowClass,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val coreState by viewModel.coreState.collectAsStateWithLifecycle()
val clipboard = LocalClipboardManager.current
val picker = rememberShareFilePicker(viewModel::onFilesPicked, viewModel::onFilePickFailed)
val shareActions = rememberTransferShareActions()
LaunchedEffect(viewModel) {
viewModel.effectFlow.collect { effect ->
when (effect) {
SendEffect.OpenFilePicker -> picker.pickFiles()
SendEffect.OpenFolderPicker -> picker.pickFolder()
is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket))
}
}
@@ -35,16 +33,7 @@ fun SendRoute(
state = state,
windowClass = windowClass,
shareActions = shareActions,
onOpenComposer = viewModel::openComposer,
onDismissComposer = viewModel::dismissComposer,
onSelectFile = viewModel::selectFile,
onSelectFolder = viewModel::selectFolder,
onClearFile = viewModel::clearSelectedSource,
onRemoveFile = viewModel::removeSelectedFile,
onTransferNameChanged = viewModel::setTransferName,
onSenderNameChanged = viewModel::setSenderName,
onAccessPolicyChanged = viewModel::setAccessPolicy,
onCreateShare = viewModel::createShare,
onOpenComposer = { draftViewModel.openInvitation(defaultSenderName) },
onTransferSelected = viewModel::openTransfer,
onShareTransfer = { transferId ->
viewModel.openTransfer(transferId)
@@ -63,4 +52,5 @@ fun SendRoute(
onDismissDelete = viewModel::dismissDeleteTransfer,
onConfirmDelete = viewModel::confirmDeleteTransfer,
)
TransferDraftHost(draftViewModel, windowClass, viewModel::onDraftCreated)
}

View File

@@ -10,7 +10,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.ReceiverDeliveryStatus
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.ui.components.AdaptiveDrawer
@@ -23,15 +22,6 @@ fun SendScreen(
windowClass: WindowClass,
shareActions: TransferShareActions = UnavailableTransferShareActions,
onOpenComposer: () -> Unit,
onDismissComposer: () -> Unit,
onSelectFile: () -> Unit,
onSelectFolder: () -> Unit = {},
onClearFile: () -> Unit,
onRemoveFile: (String) -> Unit = {},
onTransferNameChanged: (String) -> Unit,
onSenderNameChanged: (String) -> Unit,
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
onCreateShare: () -> Unit,
onTransferSelected: (ULong) -> Unit,
onShareTransfer: (ULong) -> Unit = {},
onStopSharing: (ULong) -> Unit = {},
@@ -87,24 +77,6 @@ fun SendScreen(
}
}
if (state.isComposerOpen) {
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismissComposer) {
TransferComposer(
coreInitialized = coreState.isInitialized,
state = state,
windowClass = windowClass,
onSelectFile = onSelectFile,
onSelectFolder = onSelectFolder,
onClearFile = onClearFile,
onRemoveFile = onRemoveFile,
onTransferNameChanged = onTransferNameChanged,
onSenderNameChanged = onSenderNameChanged,
onAccessPolicyChanged = onAccessPolicyChanged,
onCreateShare = onCreateShare,
)
}
}
val canShowDetailPanel = selectedTransfer != null && when (state.detailPanel) {
TransferDetailPanel.Share -> selectedTransfer.status in setOf(TransferStatus.Importing, TransferStatus.Sharing)
TransferDetailPanel.Activity, TransferDetailPanel.Receivers -> true

View File

@@ -4,13 +4,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.ReceiverRequestModel
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
@@ -25,18 +21,11 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.send_transfer_created
import vnidrop.shared.generated.resources.transfer_deleted
import vnidrop.shared.generated.resources.transfer_invitation_saved
import vnidrop.shared.generated.resources.transfer_nfc_written
data class SendState(
val isComposerOpen: Boolean = false,
val selectedFiles: List<PickedShareFile> = emptyList(),
val transferName: String = "",
val senderName: String = "",
val accessPolicy: ShareAccessPolicy = ShareAccessPolicy.RequireApproval,
val isSharing: Boolean = false,
val selectedTransferId: ULong? = null,
val transferThumbnails: Map<ULong, ByteArray> = emptyMap(),
val detailPanel: TransferDetailPanel? = null,
@@ -46,27 +35,16 @@ data class SendState(
val isDeleteConfirmationOpen: Boolean = false,
val deleteTargetTransferId: ULong? = null,
val isDeleting: Boolean = false,
) {
val selectedFile: PickedShareFile? get() = selectedFiles.singleOrNull()
val totalSelectedBytes: ULong
get() = selectedFiles.fold(0UL) { acc, file -> acc + (file.sizeBytes ?: 0UL) }
fun canCreateShare(coreInitialized: Boolean): Boolean =
coreInitialized && selectedFiles.isNotEmpty() && transferName.isNotBlank() && !isSharing
}
)
enum class TransferDetailPanel { Activity, Receivers, Share }
sealed interface SendEffect {
data object OpenFilePicker : SendEffect
data object OpenFolderPicker : SendEffect
data class CopyTicket(val ticket: String) : SendEffect
}
class SendViewModel(
private val repository: CoreGateway,
private val fileSystemService: FileSystemService,
preferencesRepository: PreferencesRepository,
private val filePreviewRepository: FilePreviewRepository,
private val messages: UiMessageController,
) : ViewModel() {
@@ -118,87 +96,18 @@ class SendViewModel(
if (activeIds != null) filePreviewRepository.restore(activeIds)
}
}
viewModelScope.launch {
preferencesRepository.preferences.collect { preferences ->
_state.update { current ->
if (current.senderName.isBlank()) current.copy(senderName = preferences.username) else current
}
}
}
}
fun openComposer() {
if (_state.value.isSharing) return
val discardedFiles = _state.value.selectedFiles
fun onDraftCreated(creation: TransferDraftCreation) {
if (creation !is TransferDraftCreation.Invitation) return
_state.update {
it.copy(
isComposerOpen = true,
selectedFiles = emptyList(),
transferName = "",
accessPolicy = ShareAccessPolicy.RequireApproval,
selectedTransferId = creation.transferId,
detailPanel = TransferDetailPanel.Share,
)
}
discardPickedFiles(discardedFiles)
refreshReceivers(creation.transferId)
}
fun dismissComposer() {
if (_state.value.isSharing) return
val discardedFiles = _state.value.selectedFiles
_state.update {
it.copy(
isComposerOpen = false,
selectedFiles = emptyList(),
transferName = "",
accessPolicy = ShareAccessPolicy.RequireApproval,
)
}
discardPickedFiles(discardedFiles)
}
fun selectFile() = sendEffect(SendEffect.OpenFilePicker)
fun selectFolder() = sendEffect(SendEffect.OpenFolderPicker)
fun onFilesPicked(files: List<PickedShareFile>) {
if (files.isEmpty()) return
val selectedValues = files.mapTo(mutableSetOf(), PickedShareFile::value)
val discardedFiles = _state.value.selectedFiles.filterNot { it.value in selectedValues }
_state.update {
it.copy(
isComposerOpen = true,
selectedFiles = files,
transferName = defaultTransferName(files),
)
}
discardPickedFiles(discardedFiles)
}
fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason.takeIf(String::isNotBlank) ?: "selection failed"))
fun clearSelectedSource() {
val discardedFiles = _state.value.selectedFiles
_state.update { it.copy(selectedFiles = emptyList(), transferName = "") }
discardPickedFiles(discardedFiles)
}
fun removeSelectedFile(value: String) {
val discardedFiles = _state.value.selectedFiles.filter { it.value == value }
_state.update { current ->
val remaining = current.selectedFiles.filterNot { it.value == value }
current.copy(
selectedFiles = remaining,
transferName = when {
remaining.isEmpty() -> ""
current.transferName == defaultTransferName(current.selectedFiles) -> defaultTransferName(remaining)
else -> current.transferName
},
)
}
discardPickedFiles(discardedFiles)
}
fun setTransferName(value: String) = _state.update { it.copy(transferName = value) }
fun setSenderName(value: String) = _state.update { it.copy(senderName = value) }
fun setAccessPolicy(value: ShareAccessPolicy) = _state.update { it.copy(accessPolicy = value) }
fun openTransfer(transferId: ULong) {
_state.update { it.copy(selectedTransferId = transferId, detailPanel = null) }
refreshReceivers(transferId)
@@ -278,47 +187,6 @@ class SendViewModel(
)
}
fun createShare() {
val current = state.value
if (current.selectedFiles.isEmpty()) return
if (!current.canCreateShare(coreState.value.isInitialized)) return
viewModelScope.launch {
_state.update { it.copy(isSharing = true) }
val result = fileSystemService.sharePickedFiles(
repository = repository,
files = current.selectedFiles,
transferName = current.transferName.trim(),
senderName = current.senderName.trim(),
accessPolicy = current.accessPolicy,
)
if (result.isSuccess) fileSystemService.discardPickedFiles(current.selectedFiles)
result.fold(
onSuccess = { share ->
current.selectedFiles.firstNotNullOfOrNull { it.thumbnailBytes }
?.let { filePreviewRepository.save(share.transferId, it) }
repository.refresh()
_state.update {
it.copy(
isComposerOpen = false,
selectedFiles = emptyList(),
transferName = "",
accessPolicy = ShareAccessPolicy.RequireApproval,
isSharing = false,
selectedTransferId = share.transferId,
detailPanel = TransferDetailPanel.Share,
)
}
refreshReceivers(share.transferId)
messages.show(UiMessage(UiText.Resource(Res.string.send_transfer_created), UiMessageTone.Success))
},
onFailure = { error ->
_state.update { it.copy(isSharing = false) }
messages.error(error)
},
)
}
}
fun stopSharing(transferId: ULong) {
viewModelScope.launch {
repository.cancel(transferId).fold(
@@ -328,23 +196,10 @@ class SendViewModel(
}
}
private fun defaultTransferName(files: List<PickedShareFile>): String = when {
files.isEmpty() -> ""
files.size == 1 && files.first().isDirectory -> files.first().displayName
files.size == 1 -> files.first().displayName
files.all { it.isDirectory } -> "${files.size} folders"
else -> "${files.size} files"
}
private fun sendEffect(effect: SendEffect) {
viewModelScope.launch { effects.send(effect) }
}
private fun discardPickedFiles(files: List<PickedShareFile>) {
if (files.isEmpty()) return
viewModelScope.launch { fileSystemService.discardPickedFiles(files) }
}
private fun refreshReceivers(transferId: ULong) {
viewModelScope.launch {
_state.update { it.copy(isLoadingReceivers = true) }

View File

@@ -29,7 +29,6 @@ import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.ui.components.Field
import com.vnidrop.app.ui.components.PrimaryButton
@@ -62,26 +61,27 @@ import vnidrop.shared.generated.resources.send_file_size_unknown
import vnidrop.shared.generated.resources.send_folder_label
import vnidrop.shared.generated.resources.send_review_title
import vnidrop.shared.generated.resources.send_selected_files_count
import vnidrop.shared.generated.resources.saved_devices_send_action
@Composable
internal fun TransferComposer(
coreInitialized: Boolean,
state: SendState,
state: TransferDraftState,
windowClass: WindowClass,
onSelectFile: () -> Unit,
onSelectFolder: () -> Unit,
onClearFile: () -> Unit,
onRemoveFile: (String) -> Unit,
onRemoveFile: (DraftSourceId) -> Unit,
onTransferNameChanged: (String) -> Unit,
onSenderNameChanged: (String) -> Unit,
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
onCreateShare: () -> Unit,
onSubmit: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (state.selectedFiles.isEmpty()) {
if (state.sources.isEmpty()) {
ChooseFileStep(onSelectFile, onSelectFolder)
} else {
ReviewFileStep(
@@ -94,7 +94,7 @@ internal fun TransferComposer(
onTransferNameChanged = onTransferNameChanged,
onSenderNameChanged = onSenderNameChanged,
onAccessPolicyChanged = onAccessPolicyChanged,
onCreateShare = onCreateShare,
onSubmit = onSubmit,
coreInitialized = coreInitialized,
)
}
@@ -124,73 +124,83 @@ private fun ChooseFileStep(onSelectFile: () -> Unit, onSelectFolder: () -> Unit)
@Composable
private fun ReviewFileStep(
state: SendState,
state: TransferDraftState,
windowClass: WindowClass,
onSelectFile: () -> Unit,
onSelectFolder: () -> Unit,
onClearFile: () -> Unit,
onRemoveFile: (String) -> Unit,
onRemoveFile: (DraftSourceId) -> Unit,
onTransferNameChanged: (String) -> Unit,
onSenderNameChanged: (String) -> Unit,
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
onCreateShare: () -> Unit,
onSubmit: () -> Unit,
coreInitialized: Boolean,
) {
Text(stringResource(Res.string.send_review_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
if (state.selectedFiles.size > 1) {
if (state.sources.size > 1) {
Text(
stringResource(Res.string.send_selected_files_count, state.selectedFiles.size),
stringResource(Res.string.send_selected_files_count, state.sources.size),
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodyMedium,
)
}
state.selectedFiles.forEach { file ->
state.sources.forEach { file ->
SelectedFileCard(
file = file,
canRemove = state.selectedFiles.size > 1 && !state.isSharing,
onRemove = { onRemoveFile(file.value) },
canRemove = state.sources.size > 1 && !state.isSubmitting,
onRemove = { onRemoveFile(file.id) },
)
}
Field(state.transferName, onTransferNameChanged, stringResource(Res.string.field_transfer_name))
Field(state.senderName, onSenderNameChanged, stringResource(Res.string.field_sender_name))
Text(stringResource(Res.string.send_access_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
PolicyOption(
icon = AppIcon.Shield,
title = stringResource(Res.string.send_access_approval),
description = stringResource(Res.string.send_access_approval_description),
selected = state.accessPolicy == ShareAccessPolicy.RequireApproval,
onClick = { onAccessPolicyChanged(ShareAccessPolicy.RequireApproval) },
)
PolicyOption(
icon = AppIcon.Globe,
title = stringResource(Res.string.send_access_anyone),
description = stringResource(Res.string.send_access_anyone_description),
selected = state.accessPolicy == ShareAccessPolicy.AnyoneWithTransfer,
onClick = { onAccessPolicyChanged(ShareAccessPolicy.AnyoneWithTransfer) },
)
if (state.accessPolicy == ShareAccessPolicy.AnyoneWithTransfer) {
Text(
stringResource(Res.string.send_access_anyone_warning),
color = LocalVniDropColors.current.destructiveDefault,
style = MaterialTheme.typography.bodySmall,
Field(state.transferName, onTransferNameChanged, stringResource(Res.string.field_transfer_name), enabled = !state.isSubmitting)
when (val destination = state.destination) {
TransferDraftDestination.Invitation -> {
Field(state.senderName, onSenderNameChanged, stringResource(Res.string.field_sender_name), enabled = !state.isSubmitting)
Text(stringResource(Res.string.send_access_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
PolicyOption(
icon = AppIcon.Shield,
title = stringResource(Res.string.send_access_approval),
description = stringResource(Res.string.send_access_approval_description),
selected = state.accessPolicy == ShareAccessPolicy.RequireApproval,
onClick = { onAccessPolicyChanged(ShareAccessPolicy.RequireApproval) },
)
PolicyOption(
icon = AppIcon.Globe,
title = stringResource(Res.string.send_access_anyone),
description = stringResource(Res.string.send_access_anyone_description),
selected = state.accessPolicy == ShareAccessPolicy.AnyoneWithTransfer,
onClick = { onAccessPolicyChanged(ShareAccessPolicy.AnyoneWithTransfer) },
)
if (state.accessPolicy == ShareAccessPolicy.AnyoneWithTransfer) {
Text(
stringResource(Res.string.send_access_anyone_warning),
color = LocalVniDropColors.current.destructiveDefault,
style = MaterialTheme.typography.bodySmall,
)
}
}
is TransferDraftDestination.Targeted -> Text(
destination.receiver.displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
null -> Unit
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth())
SubmitButton(state, coreInitialized, onSubmit, Modifier.fillMaxWidth())
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SourceButton(
text = stringResource(Res.string.button_change_files),
icon = AppIcon.File,
onClick = onSelectFile,
modifier = Modifier.weight(1f),
enabled = !state.isSharing,
enabled = !state.isSubmitting,
)
SourceButton(
text = stringResource(Res.string.button_choose_folder),
icon = AppIcon.Folder,
onClick = onSelectFolder,
modifier = Modifier.weight(1f),
enabled = !state.isSharing,
enabled = !state.isSubmitting,
)
if (windowClass != WindowClass.Phone) {
SourceButton(
@@ -198,7 +208,7 @@ private fun ReviewFileStep(
icon = AppIcon.Close,
onClick = onClearFile,
modifier = Modifier.weight(1f),
enabled = !state.isSharing,
enabled = !state.isSubmitting,
)
}
}
@@ -221,18 +231,23 @@ private fun SourceButton(
}
@Composable
private fun ShareButton(state: SendState, coreInitialized: Boolean, onCreateShare: () -> Unit, modifier: Modifier = Modifier) {
private fun SubmitButton(state: TransferDraftState, coreInitialized: Boolean, onSubmit: () -> Unit, modifier: Modifier = Modifier) {
val targeted = state.destination is TransferDraftDestination.Targeted
PrimaryButton(
if (state.isSharing) stringResource(Res.string.button_sharing_file) else stringResource(Res.string.button_share_file),
onClick = onCreateShare,
when {
state.isSubmitting -> stringResource(Res.string.button_sharing_file)
targeted -> stringResource(Res.string.saved_devices_send_action)
else -> stringResource(Res.string.button_share_file)
},
onClick = onSubmit,
modifier = modifier,
enabled = state.canCreateShare(coreInitialized),
enabled = state.canSubmit(coreInitialized),
)
}
@Composable
private fun SelectedFileCard(
file: PickedShareFile,
file: TransferDraftSource,
canRemove: Boolean,
onRemove: () -> Unit,
) {

View File

@@ -0,0 +1,60 @@
package com.vnidrop.app.feature.send
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vnidrop.app.core.rememberShareFilePicker
import com.vnidrop.app.ui.components.AdaptiveDrawer
import com.vnidrop.app.ui.state.WindowClass
@Composable
internal fun TransferDraftHost(
viewModel: TransferDraftViewModel,
windowClass: WindowClass,
onCreated: (TransferDraftCreation) -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val coreState by viewModel.coreState.collectAsStateWithLifecycle()
var activePickerRequestId by remember { mutableStateOf<Long?>(null) }
val picker = rememberShareFilePicker(
onFilesPicked = { files -> activePickerRequestId?.let { viewModel.onFilesPicked(it, files) } },
onError = { reason -> activePickerRequestId?.let { viewModel.onFilePickFailed(it, reason) } },
)
LaunchedEffect(viewModel) {
viewModel.effectFlow.collect { effect ->
when (effect) {
is TransferDraftEffect.OpenPicker -> {
activePickerRequestId = effect.requestId
when (effect.kind) {
TransferDraftPickKind.Files -> picker.pickFiles()
TransferDraftPickKind.Folder -> picker.pickFolder()
}
}
is TransferDraftEffect.Created -> onCreated(effect.creation)
}
}
}
if (state.isOpen) {
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = viewModel::dismiss) {
TransferComposer(
coreInitialized = coreState.isInitialized,
state = state,
windowClass = windowClass,
onSelectFile = viewModel::chooseFiles,
onSelectFolder = viewModel::chooseFolder,
onClearFile = viewModel::clearSources,
onRemoveFile = viewModel::removeSource,
onTransferNameChanged = viewModel::changeTransferName,
onSenderNameChanged = viewModel::changeSenderName,
onAccessPolicyChanged = viewModel::changeAccessPolicy,
onSubmit = viewModel::submit,
)
}
}
}

View File

@@ -0,0 +1,367 @@
package com.vnidrop.app.feature.send
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.PickedShareSourceAdapter
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.SavedDeviceModel
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.CancellationException
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.saved_devices_send_started
import vnidrop.shared.generated.resources.send_default_transfer_name
import vnidrop.shared.generated.resources.send_transfer_created
sealed interface TransferDraftDestination {
data object Invitation : TransferDraftDestination
data class Targeted(
val receiver: LockedSavedDevice,
) : TransferDraftDestination
}
data class LockedSavedDevice(
val endpointId: String,
val displayName: String,
)
@JvmInline
value class DraftSourceId(val value: String)
data class TransferDraftSource(
val id: DraftSourceId,
val displayName: String,
val sizeBytes: ULong?,
val thumbnailBytes: ByteArray?,
val isDirectory: Boolean,
)
enum class TransferDraftPickKind { Files, Folder }
data class TransferDraftState(
val destination: TransferDraftDestination? = null,
val sources: List<TransferDraftSource> = emptyList(),
val transferName: String = "",
val senderName: String = "",
val accessPolicy: ShareAccessPolicy = ShareAccessPolicy.RequireApproval,
val pickerRequestId: Long? = null,
val isPreparingSources: Boolean = false,
val isSubmitting: Boolean = false,
) {
val isOpen: Boolean get() = destination != null
val isPicking: Boolean get() = pickerRequestId != null || isPreparingSources
val totalSelectedBytes: ULong
get() = sources.fold(0UL) { total, source -> total + (source.sizeBytes ?: 0UL) }
fun canSubmit(coreInitialized: Boolean): Boolean =
coreInitialized && sources.isNotEmpty() && transferName.isNotBlank() && !isPicking && !isSubmitting
}
sealed interface TransferDraftCreation {
data class Invitation(val transferId: ULong) : TransferDraftCreation
data class Targeted(val transferId: String, val receiverEndpointId: String) : TransferDraftCreation
}
sealed interface TransferDraftEffect {
data class OpenPicker(val requestId: Long, val kind: TransferDraftPickKind) : TransferDraftEffect
data class Created(val creation: TransferDraftCreation) : TransferDraftEffect
}
internal class TransferDraftViewModel(
private val repository: CoreGateway,
private val sourceAdapter: PickedShareSourceAdapter,
private val filePreviewRepository: FilePreviewRepository,
private val messages: UiMessageController,
private val multipleFilesName: suspend (Int) -> String = { count ->
getString(Res.string.send_default_transfer_name, count)
},
) : ViewModel() {
private data class SelectedSource(
val source: TransferDraftSource,
val picked: PickedShareFile,
)
private val _state = MutableStateFlow(TransferDraftState())
val state: StateFlow<TransferDraftState> = _state.asStateFlow()
val coreState = repository.state
private val effects = Channel<TransferDraftEffect>(Channel.BUFFERED)
val effectFlow = effects.receiveAsFlow()
private var selectedSources = emptyList<SelectedSource>()
private var nextPickerRequestId = 1L
private var nextSourceId = 1L
private var automaticName = true
fun openInvitation(defaultSenderName: String) {
if (_state.value.isOpen) return
reset(
TransferDraftState(
destination = TransferDraftDestination.Invitation,
senderName = defaultSenderName,
),
)
}
fun openTargeted(device: SavedDeviceModel, unnamedDeviceName: String) {
if (_state.value.isOpen) return
val displayName = device.localLabel?.takeIf(String::isNotBlank)
?: device.remoteDisplayName?.takeIf(String::isNotBlank)
?: unnamedDeviceName
reset(
TransferDraftState(
destination = TransferDraftDestination.Targeted(
LockedSavedDevice(device.endpointId, displayName),
),
),
)
}
fun chooseFiles() = requestPicker(TransferDraftPickKind.Files)
fun chooseFolder() = requestPicker(TransferDraftPickKind.Folder)
fun onFilesPicked(requestId: Long, files: List<PickedShareFile>) {
if (_state.value.pickerRequestId != requestId) {
discard(files)
return
}
if (files.isEmpty()) {
_state.update { it.copy(pickerRequestId = null) }
return
}
val validSelection = files.none(PickedShareFile::isDirectory) ||
(files.size == 1 && files.single().isDirectory)
if (!validSelection) {
_state.update { it.copy(pickerRequestId = null) }
discard(files)
messages.error(IllegalArgumentException("Choose multiple files or one folder"))
return
}
_state.update { it.copy(isPreparingSources = true) }
viewModelScope.launch {
val newName = try {
when {
files.size == 1 -> files.single().displayName
else -> multipleFilesName(files.size)
}
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
_state.update { it.copy(pickerRequestId = null, isPreparingSources = false) }
discardOwned(files)
messages.error(error)
return@launch
}
if (_state.value.pickerRequestId != requestId) {
discardOwned(files)
return@launch
}
val previous = selectedSources
selectedSources = files.map { file ->
SelectedSource(
source = TransferDraftSource(
id = DraftSourceId("source-${nextSourceId++}"),
displayName = file.displayName,
sizeBytes = file.sizeBytes,
thumbnailBytes = file.thumbnailBytes,
isDirectory = file.isDirectory,
),
picked = file,
)
}
automaticName = true
_state.update {
it.copy(
sources = selectedSources.map(SelectedSource::source),
transferName = newName,
pickerRequestId = null,
isPreparingSources = false,
)
}
val replacementValues = files.mapTo(mutableSetOf(), PickedShareFile::value)
discardOwned(previous.map(SelectedSource::picked).filterNot { it.value in replacementValues })
}
}
fun onFilePickFailed(requestId: Long, reason: String) {
if (_state.value.pickerRequestId != requestId) return
_state.update { it.copy(pickerRequestId = null) }
messages.error(IllegalStateException(reason.ifBlank { "selection failed" }))
}
fun clearSources() {
if (!editable()) return
val discarded = selectedSources.map(SelectedSource::picked)
selectedSources = emptyList()
automaticName = true
_state.update { it.copy(sources = emptyList(), transferName = "") }
discard(discarded)
}
fun removeSource(id: DraftSourceId) {
if (!editable()) return
val discarded = selectedSources.filter { it.source.id == id }.map(SelectedSource::picked)
if (discarded.isEmpty()) return
selectedSources = selectedSources.filterNot { it.source.id == id }
_state.update {
it.copy(
sources = selectedSources.map(SelectedSource::source),
isPreparingSources = true,
)
}
viewModelScope.launch {
val replacementName = try {
when {
!automaticName -> _state.value.transferName
selectedSources.isEmpty() -> ""
selectedSources.size == 1 -> selectedSources.single().source.displayName
else -> multipleFilesName(selectedSources.size)
}
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
_state.update { it.copy(isPreparingSources = false) }
discardOwned(discarded)
messages.error(error)
return@launch
}
_state.update {
it.copy(
transferName = replacementName,
isPreparingSources = false,
)
}
discardOwned(discarded)
}
}
fun changeTransferName(value: String) {
if (!editable()) return
automaticName = false
_state.update { it.copy(transferName = value) }
}
fun changeSenderName(value: String) {
if (!editable() || _state.value.destination !is TransferDraftDestination.Invitation) return
_state.update { it.copy(senderName = value) }
}
fun changeAccessPolicy(value: ShareAccessPolicy) {
if (!editable() || _state.value.destination !is TransferDraftDestination.Invitation) return
_state.update { it.copy(accessPolicy = value) }
}
fun submit() {
val current = _state.value
if (!current.canSubmit(repository.state.value.isInitialized)) return
val files = selectedSources.map(SelectedSource::picked)
val thumbnail = selectedSources.firstNotNullOfOrNull { it.source.thumbnailBytes }
viewModelScope.launch {
_state.update { it.copy(isSubmitting = true) }
val result = runCatching {
val destination = current.destination ?: error("Transfer draft is closed")
if (destination is TransferDraftDestination.Targeted) {
val stillSaved = repository.listSavedDevices().getOrThrow()
.any { it.endpointId == destination.receiver.endpointId }
check(stillSaved) { "Saved device is no longer available" }
}
sourceAdapter.withShareSources(files) { sources ->
when (destination) {
TransferDraftDestination.Invitation -> repository.shareSources(
sources = sources,
transferName = current.transferName.trim(),
senderName = current.senderName.trim(),
accessPolicy = current.accessPolicy,
).getOrThrow().let { share -> TransferDraftCreation.Invitation(share.transferId) }
is TransferDraftDestination.Targeted -> repository.createTargetedTransfer(
receiverEndpointId = destination.receiver.endpointId,
sources = sources,
transferName = current.transferName.trim(),
).getOrThrow().let { transfer ->
TransferDraftCreation.Targeted(transfer.id, destination.receiver.endpointId)
}
}
}.getOrThrow()
}
result.fold(
onSuccess = { creation ->
if (creation is TransferDraftCreation.Invitation) {
runCatching {
thumbnail?.let { filePreviewRepository.save(creation.transferId, it) }
repository.refresh().getOrThrow()
}.onFailure(messages::error)
}
discardOwned(files)
selectedSources = emptyList()
automaticName = true
_state.value = TransferDraftState()
effects.send(TransferDraftEffect.Created(creation))
val message = when (creation) {
is TransferDraftCreation.Invitation -> Res.string.send_transfer_created
is TransferDraftCreation.Targeted -> Res.string.saved_devices_send_started
}
messages.show(UiMessage(UiText.Resource(message), UiMessageTone.Success))
},
onFailure = { error ->
if (error is CancellationException) throw error
_state.update { it.copy(isSubmitting = false) }
messages.error(error)
},
)
}
}
fun dismiss() {
if (_state.value.isSubmitting) return
val discarded = selectedSources.map(SelectedSource::picked)
selectedSources = emptyList()
automaticName = true
_state.value = TransferDraftState()
discard(discarded)
}
private fun requestPicker(kind: TransferDraftPickKind) {
if (!editable() || _state.value.isPicking) return
val requestId = nextPickerRequestId++
_state.update { it.copy(pickerRequestId = requestId) }
viewModelScope.launch { effects.send(TransferDraftEffect.OpenPicker(requestId, kind)) }
}
private fun editable(): Boolean = _state.value.isOpen && !_state.value.isPicking && !_state.value.isSubmitting
private fun reset(state: TransferDraftState) {
selectedSources = emptyList()
automaticName = true
_state.value = state
}
private fun discard(files: List<PickedShareFile>) {
if (files.isEmpty()) return
viewModelScope.launch { discardOwned(files) }
}
private suspend fun discardOwned(files: List<PickedShareFile>) {
if (files.isEmpty()) return
try {
sourceAdapter.discardPickedFiles(files)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
messages.error(error)
}
}
}

View File

@@ -1,66 +0,0 @@
package com.vnidrop.app.feature.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import com.vnidrop.app.feature.saveddevices.SavedDevicesPanel
import com.vnidrop.app.feature.saveddevices.SavedDevicesState
import com.vnidrop.app.ui.icons.AppIcon
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.experimental_saved_devices_description
import vnidrop.shared.generated.resources.experimental_saved_devices_title
import vnidrop.shared.generated.resources.experimental_settings_title
@Composable
internal fun ExperimentalSettings(
state: SettingsState,
savedDevicesState: SavedDevicesState,
onSavedDevicesEnabledChanged: (Boolean) -> Unit,
onRememberEligible: (String) -> Unit,
onDeclineEligible: (String) -> Unit,
onAcceptIncoming: (String) -> Unit,
onDeclineIncoming: (String) -> Unit,
onSendToDevice: (String) -> Unit,
onOpenDeviceLabel: (String) -> Unit,
onForgetDevice: (String) -> Unit,
onBlockDevice: (String) -> Unit,
onLabelDraftChanged: (String) -> Unit,
onSaveDeviceLabel: () -> Unit,
onClearDeviceLabel: () -> Unit,
onDismissDeviceLabel: () -> Unit,
onBack: () -> Unit,
showBack: Boolean,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SettingsTopBar(stringResource(Res.string.experimental_settings_title), onBack, showBack)
SettingsGroup {
SettingsToggleRow(
icon = AppIcon.Lock,
title = stringResource(Res.string.experimental_saved_devices_title),
description = stringResource(Res.string.experimental_saved_devices_description),
checked = state.experimentalSavedDevicesEnabled,
enabled = true,
onCheckedChange = onSavedDevicesEnabledChanged,
)
}
if (state.experimentalSavedDevicesEnabled) {
SavedDevicesPanel(
state = savedDevicesState,
onRememberEligible = onRememberEligible,
onDeclineEligible = onDeclineEligible,
onAcceptIncoming = onAcceptIncoming,
onDeclineIncoming = onDeclineIncoming,
onSend = onSendToDevice,
onOpenLabel = onOpenDeviceLabel,
onForget = onForgetDevice,
onBlock = onBlockDevice,
onLabelDraftChanged = onLabelDraftChanged,
onSaveLabel = onSaveDeviceLabel,
onClearLabel = onClearDeviceLabel,
onDismissLabel = onDismissDeviceLabel,
)
}
}
}

View File

@@ -12,7 +12,6 @@ import com.vnidrop.app.ui.icons.AppIcon
import com.vnidrop.app.ui.theme.ThemeMode
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.experimental_settings_title
import vnidrop.shared.generated.resources.notifications_title
import vnidrop.shared.generated.resources.preferences_title
import vnidrop.shared.generated.resources.relay_mode_automatic
@@ -33,7 +32,6 @@ internal fun SettingsOverview(
state: SettingsState,
onSectionSelected: (SettingsSection) -> Unit,
largeTitle: Boolean,
showExperimental: Boolean = false,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
@@ -82,16 +80,6 @@ internal fun SettingsOverview(
onClick = { onSectionSelected(SettingsSection.Network) },
)
}
if (showExperimental) {
SettingsGroup {
SettingsRow(
icon = AppIcon.Lock,
title = stringResource(Res.string.experimental_settings_title),
selected = state.selectedSection == SettingsSection.Experimental,
onClick = { onSectionSelected(SettingsSection.Experimental) },
)
}
}
SettingsGroup {
SettingsRow(
icon = AppIcon.Info,

View File

@@ -4,28 +4,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vnidrop.app.showsExperimentalSavedDevices
import com.vnidrop.app.core.rememberReceiveFolderPicker
import com.vnidrop.app.core.rememberShareFilePicker
import com.vnidrop.app.feature.saveddevices.SavedDevicesEffect
import com.vnidrop.app.feature.saveddevices.SavedDevicesViewModel
import com.vnidrop.app.ui.platform.LocalUiPlatform
import com.vnidrop.app.ui.state.WindowClass
@Composable
fun SettingsRoute(
internal fun SettingsRoute(
viewModel: SettingsViewModel,
savedDevicesViewModel: SavedDevicesViewModel,
windowClass: WindowClass,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val savedDevicesState by savedDevicesViewModel.state.collectAsStateWithLifecycle()
val showExperimental = showsExperimentalSavedDevices(LocalUiPlatform.current)
val folderPicker = rememberReceiveFolderPicker(viewModel::onReceiveFolderPicked, viewModel::onReceiveFolderPickFailed)
val sharePicker = rememberShareFilePicker(
savedDevicesViewModel::onFilesPicked,
savedDevicesViewModel::onFilePickFailed,
)
LaunchedEffect(viewModel) {
viewModel.effectFlow.collect { effect ->
when (effect) {
@@ -33,13 +21,6 @@ fun SettingsRoute(
}
}
}
LaunchedEffect(savedDevicesViewModel) {
savedDevicesViewModel.effectFlow.collect { effect ->
when (effect) {
SavedDevicesEffect.OpenFilePicker -> sharePicker.pickFiles()
}
}
}
SettingsScreen(
state = state,
windowClass = windowClass,
@@ -54,21 +35,6 @@ fun SettingsRoute(
onChooseFolder = viewModel::chooseReceiveFolder,
onResetFolder = viewModel::resetReceiveFolder,
onNotificationsChanged = viewModel::setNotificationsEnabled,
onExperimentalSavedDevicesChanged = viewModel::setExperimentalSavedDevicesEnabled,
showExperimental = showExperimental,
savedDevicesState = savedDevicesState,
onRememberEligibleDevice = savedDevicesViewModel::rememberEligible,
onDeclineEligibleDevice = savedDevicesViewModel::declineEligible,
onAcceptIncomingPairing = savedDevicesViewModel::acceptIncoming,
onDeclineIncomingPairing = savedDevicesViewModel::declineIncoming,
onSendToSavedDevice = savedDevicesViewModel::startSend,
onOpenSavedDeviceLabel = savedDevicesViewModel::openLabelEditor,
onForgetSavedDevice = savedDevicesViewModel::forget,
onBlockSavedDevice = savedDevicesViewModel::block,
onSavedDeviceLabelDraftChanged = savedDevicesViewModel::setLabelDraft,
onSaveSavedDeviceLabel = savedDevicesViewModel::saveLabel,
onClearSavedDeviceLabel = savedDevicesViewModel::clearLabel,
onDismissSavedDeviceLabel = savedDevicesViewModel::dismissLabelEditor,
onOpenNotificationSettings = viewModel::openNotificationSettings,
onBugWhatChanged = viewModel::setBugWhatHappened,
onBugExpectedChanged = viewModel::setBugExpected,

View File

@@ -9,7 +9,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.RelayMode
import com.vnidrop.app.feature.saveddevices.SavedDevicesState
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.theme.ThemeMode
@@ -24,21 +23,6 @@ fun SettingsScreen(
onChooseFolder: () -> Unit,
onResetFolder: () -> Unit,
onNotificationsChanged: (Boolean) -> Unit,
onExperimentalSavedDevicesChanged: (Boolean) -> Unit = {},
showExperimental: Boolean = false,
savedDevicesState: SavedDevicesState = SavedDevicesState(),
onRememberEligibleDevice: (String) -> Unit = {},
onDeclineEligibleDevice: (String) -> Unit = {},
onAcceptIncomingPairing: (String) -> Unit = {},
onDeclineIncomingPairing: (String) -> Unit = {},
onSendToSavedDevice: (String) -> Unit = {},
onOpenSavedDeviceLabel: (String) -> Unit = {},
onForgetSavedDevice: (String) -> Unit = {},
onBlockSavedDevice: (String) -> Unit = {},
onSavedDeviceLabelDraftChanged: (String) -> Unit = {},
onSaveSavedDeviceLabel: () -> Unit = {},
onClearSavedDeviceLabel: () -> Unit = {},
onDismissSavedDeviceLabel: () -> Unit = {},
onOpenNotificationSettings: () -> Unit,
onBugWhatChanged: (String) -> Unit,
onBugExpectedChanged: (String) -> Unit,
@@ -66,7 +50,6 @@ fun SettingsScreen(
state,
onSectionSelected,
largeTitle = false,
showExperimental = showExperimental,
)
}
Column(Modifier.weight(1f)) {
@@ -83,20 +66,6 @@ fun SettingsScreen(
onChooseFolder = onChooseFolder,
onResetFolder = onResetFolder,
onNotificationsChanged = onNotificationsChanged,
onExperimentalSavedDevicesChanged = onExperimentalSavedDevicesChanged,
savedDevicesState = savedDevicesState,
onRememberEligibleDevice = onRememberEligibleDevice,
onDeclineEligibleDevice = onDeclineEligibleDevice,
onAcceptIncomingPairing = onAcceptIncomingPairing,
onDeclineIncomingPairing = onDeclineIncomingPairing,
onSendToSavedDevice = onSendToSavedDevice,
onOpenSavedDeviceLabel = onOpenSavedDeviceLabel,
onForgetSavedDevice = onForgetSavedDevice,
onBlockSavedDevice = onBlockSavedDevice,
onSavedDeviceLabelDraftChanged = onSavedDeviceLabelDraftChanged,
onSaveSavedDeviceLabel = onSaveSavedDeviceLabel,
onClearSavedDeviceLabel = onClearSavedDeviceLabel,
onDismissSavedDeviceLabel = onDismissSavedDeviceLabel,
onOpenNotificationSettings = onOpenNotificationSettings,
onBugWhatChanged = onBugWhatChanged,
onBugExpectedChanged = onBugExpectedChanged,
@@ -122,7 +91,6 @@ fun SettingsScreen(
state,
onSectionSelected,
largeTitle = true,
showExperimental = showExperimental,
)
else -> SettingsSectionContent(
state = state,
@@ -144,20 +112,6 @@ fun SettingsScreen(
onChooseFolder = onChooseFolder,
onResetFolder = onResetFolder,
onNotificationsChanged = onNotificationsChanged,
onExperimentalSavedDevicesChanged = onExperimentalSavedDevicesChanged,
savedDevicesState = savedDevicesState,
onRememberEligibleDevice = onRememberEligibleDevice,
onDeclineEligibleDevice = onDeclineEligibleDevice,
onAcceptIncomingPairing = onAcceptIncomingPairing,
onDeclineIncomingPairing = onDeclineIncomingPairing,
onSendToSavedDevice = onSendToSavedDevice,
onOpenSavedDeviceLabel = onOpenSavedDeviceLabel,
onForgetSavedDevice = onForgetSavedDevice,
onBlockSavedDevice = onBlockSavedDevice,
onSavedDeviceLabelDraftChanged = onSavedDeviceLabelDraftChanged,
onSaveSavedDeviceLabel = onSaveSavedDeviceLabel,
onClearSavedDeviceLabel = onClearSavedDeviceLabel,
onDismissSavedDeviceLabel = onDismissSavedDeviceLabel,
onOpenNotificationSettings = onOpenNotificationSettings,
onBugWhatChanged = onBugWhatChanged,
onBugExpectedChanged = onBugExpectedChanged,
@@ -192,20 +146,6 @@ private fun SettingsSectionContent(
onChooseFolder: () -> Unit,
onResetFolder: () -> Unit,
onNotificationsChanged: (Boolean) -> Unit,
onExperimentalSavedDevicesChanged: (Boolean) -> Unit,
savedDevicesState: SavedDevicesState,
onRememberEligibleDevice: (String) -> Unit,
onDeclineEligibleDevice: (String) -> Unit,
onAcceptIncomingPairing: (String) -> Unit,
onDeclineIncomingPairing: (String) -> Unit,
onSendToSavedDevice: (String) -> Unit,
onOpenSavedDeviceLabel: (String) -> Unit,
onForgetSavedDevice: (String) -> Unit,
onBlockSavedDevice: (String) -> Unit,
onSavedDeviceLabelDraftChanged: (String) -> Unit,
onSaveSavedDeviceLabel: () -> Unit,
onClearSavedDeviceLabel: () -> Unit,
onDismissSavedDeviceLabel: () -> Unit,
onOpenNotificationSettings: () -> Unit,
onBugWhatChanged: (String) -> Unit,
onBugExpectedChanged: (String) -> Unit,
@@ -238,25 +178,6 @@ private fun SettingsSectionContent(
showBack = showBack,
)
SettingsSection.Notifications -> NotificationSettings(state, onNotificationsChanged, onOpenNotificationSettings, onBack, showBack)
SettingsSection.Experimental -> ExperimentalSettings(
state = state,
savedDevicesState = savedDevicesState,
onSavedDevicesEnabledChanged = onExperimentalSavedDevicesChanged,
onRememberEligible = onRememberEligibleDevice,
onDeclineEligible = onDeclineEligibleDevice,
onAcceptIncoming = onAcceptIncomingPairing,
onDeclineIncoming = onDeclineIncomingPairing,
onSendToDevice = onSendToSavedDevice,
onOpenDeviceLabel = onOpenSavedDeviceLabel,
onForgetDevice = onForgetSavedDevice,
onBlockDevice = onBlockSavedDevice,
onLabelDraftChanged = onSavedDeviceLabelDraftChanged,
onSaveDeviceLabel = onSaveSavedDeviceLabel,
onClearDeviceLabel = onClearSavedDeviceLabel,
onDismissDeviceLabel = onDismissSavedDeviceLabel,
onBack = onBack,
showBack = showBack,
)
SettingsSection.Storage -> StorageSettings(
state,
windowClass,

View File

@@ -58,7 +58,6 @@ enum class SettingsSection {
Network,
Notifications,
Storage,
Experimental,
About,
BugReport,
}
@@ -98,7 +97,6 @@ data class SettingsState(
val hasActiveNetworkWork: Boolean = false,
val endpointId: String? = null,
val notificationsEnabled: Boolean = false,
val experimentalSavedDevicesEnabled: Boolean = false,
val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined,
val deviceInfo: DeviceInfo? = null,
val appVersion: String = "",
@@ -162,7 +160,6 @@ class SettingsViewModel(
receiveFolder = receiveFolder,
themeMode = preferences.themeMode,
notificationsEnabled = preferences.notificationsEnabled,
experimentalSavedDevicesEnabled = preferences.experimentalSavedDevicesEnabled,
savedRelaySettings = preferences.relaySettings,
relayMode = if (hasLocalRelayDraft) current.relayMode else preferences.relaySettings.mode,
relayUrls = if (hasLocalRelayDraft) {
@@ -571,12 +568,6 @@ class SettingsViewModel(
}
}
fun setExperimentalSavedDevicesEnabled(enabled: Boolean) {
viewModelScope.launch {
preferencesRepository.setExperimentalSavedDevicesEnabled(enabled)
}
}
fun openNotificationSettings() {
viewModelScope.launch {
enableNotificationsAfterSettings = true

View File

@@ -27,8 +27,6 @@ data class AppPreferences(
/** Stable anonymous install id for bug-report correlation; never an account or advertising id. */
val diagnosticsInstallId: String = "",
val relaySettings: RelaySettings = RelaySettings(),
/** Experimental saved-devices / targeted-transfer UI (Android). Default off. */
val experimentalSavedDevicesEnabled: Boolean = false,
)
class AppPreferencesDefaults(
@@ -36,7 +34,6 @@ class AppPreferencesDefaults(
val receiveFolder: ReceiveFolder,
val themeMode: ThemeMode,
val notificationsEnabled: Boolean = false,
val experimentalSavedDevicesEnabled: Boolean = false,
)
interface PreferencesRepository {
@@ -47,7 +44,6 @@ interface PreferencesRepository {
suspend fun setThemeMode(mode: ThemeMode)
suspend fun setNotificationsEnabled(enabled: Boolean)
suspend fun setRelaySettings(settings: RelaySettings)
suspend fun setExperimentalSavedDevicesEnabled(enabled: Boolean)
/** Ensures a durable install id exists and returns it. */
suspend fun ensureDiagnosticsInstallId(): String
}
@@ -88,8 +84,6 @@ class AppPreferencesRepository(
mode = relayMode,
relayUrls = relayUrls,
),
experimentalSavedDevicesEnabled = prefs[PreferenceKeys.ExperimentalSavedDevicesEnabled]
?: defaults.experimentalSavedDevicesEnabled,
)
}
@@ -123,12 +117,6 @@ class AppPreferencesRepository(
}
}
override suspend fun setExperimentalSavedDevicesEnabled(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[PreferenceKeys.ExperimentalSavedDevicesEnabled] = enabled
}
}
override suspend fun setRelaySettings(settings: RelaySettings) {
dataStore.edit { prefs ->
prefs[PreferenceKeys.RelayMode] = settings.mode.name
@@ -161,7 +149,6 @@ private object PreferenceKeys {
val ReceiveFolderDisplayName = stringPreferencesKey("receive_folder_display_name")
val ThemeMode = stringPreferencesKey("theme_mode")
val NotificationsEnabled = booleanPreferencesKey("notifications_enabled")
val ExperimentalSavedDevicesEnabled = booleanPreferencesKey("experimental_saved_devices_enabled")
val DiagnosticsInstallId = stringPreferencesKey("diagnostics_install_id")
val RelayMode = stringPreferencesKey("relay_mode")
val RelayUrls = stringPreferencesKey("relay_urls")

View File

@@ -4,12 +4,14 @@ import com.vnidrop.app.ui.icons.AppIcon
import org.jetbrains.compose.resources.StringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.nav_receive
import vnidrop.shared.generated.resources.nav_saved_devices
import vnidrop.shared.generated.resources.nav_send
import vnidrop.shared.generated.resources.nav_settings
enum class AppDestination {
Send,
Receive,
SavedDevices,
Settings,
}
@@ -19,11 +21,9 @@ internal data class NavigationItem(
val icon: AppIcon,
)
// The route list is intentionally tiny for this phase. Activity, receiver
// requests, and diagnostics remain available inside screens instead of being
// promoted to top-level navigation.
internal val primaryNavigationItems = listOf(
NavigationItem(AppDestination.Send, Res.string.nav_send, AppIcon.Send),
NavigationItem(AppDestination.Receive, Res.string.nav_receive, AppIcon.Download),
NavigationItem(AppDestination.SavedDevices, Res.string.nav_saved_devices, AppIcon.ShieldCheck),
NavigationItem(AppDestination.Settings, Res.string.nav_settings, AppIcon.Settings),
)

View File

@@ -1,15 +0,0 @@
package com.vnidrop.app
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ExperimentalSavedDevicesGateTest {
@Test
fun experimentalChromeIsShownOnAndroidWindowsAndLinuxOnly() {
assertTrue(showsExperimentalSavedDevices(UiPlatform.Android))
assertTrue(showsExperimentalSavedDevices(UiPlatform.Windows))
assertTrue(showsExperimentalSavedDevices(UiPlatform.Linux))
assertFalse(showsExperimentalSavedDevices(UiPlatform.Desktop))
}
}

View File

@@ -6,14 +6,12 @@ import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.CoreStatus
import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.CoreStorageUsageModel
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.core.ReceiverDeliveryStatus
import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.core.RelayMode
import com.vnidrop.app.core.RelaySettings
import com.vnidrop.app.core.Share
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferDirection
@@ -26,7 +24,6 @@ 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.send.TransferDetailPanel
import com.vnidrop.app.feature.settings.SettingsSection
import com.vnidrop.app.feature.settings.RelaySettingsApplyError
import com.vnidrop.app.feature.settings.RelaySettingsInputError
@@ -55,7 +52,6 @@ import kotlinx.coroutines.test.setMain
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertContentEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@@ -472,22 +468,6 @@ class ViewModelsTest {
assertEquals(null, withTimeoutOrNull(1) { viewModel.effectFlow.first() })
}
@Test
fun sendViewModelOwnsSelectedFileState() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val fileSystem = FakeFileSystemService(folder)
val viewModel = SendViewModel(FakeCoreGateway(), fileSystem, preferences(), FakeFilePreviewRepository(), UiMessageController())
viewModel.openComposer()
val selected = PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL, isTemporaryCopy = true)
viewModel.onFilesPicked(listOf(selected))
assertEquals("photo.jpg", viewModel.state.value.transferName)
assertEquals(42UL, viewModel.state.value.selectedFile?.sizeBytes)
viewModel.clearSelectedSource()
advanceUntilIdle()
assertEquals(null, viewModel.state.value.selectedFile)
assertEquals(listOf(selected), fileSystem.discardedPickedFiles)
}
@Test
fun sendViewModelTracksReceiverCompletionForCatalogProgress() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
@@ -509,7 +489,7 @@ class ViewModelsTest {
mutableState.value = CoreState(isInitialized = true, transfers = listOf(sentTransfer(7UL)))
requests[7UL] = listOf(accepted)
}
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
val viewModel = SendViewModel(core, FakeFilePreviewRepository(), UiMessageController())
advanceUntilIdle()
assertEquals(ReceiverDeliveryStatus.Accepted, viewModel.state.value.receiversByTransfer.getValue(7UL).single().status)
@@ -520,37 +500,6 @@ class ViewModelsTest {
assertEquals(ReceiverDeliveryStatus.Completed, viewModel.state.value.receiversByTransfer.getValue(7UL).single().status)
}
@Test
fun sendComposerClosesAfterSuccessfulAtomicShareCreation() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = CoreState(isInitialized = true)
shareResult = Result.success(Share(7UL, "ticket", "photo.jpg", "hash", 1UL, 42UL))
}
val previews = FakeFilePreviewRepository()
val fileSystem = FakeFileSystemService(folder)
val viewModel = SendViewModel(core, fileSystem, preferences(), previews, UiMessageController())
advanceUntilIdle()
viewModel.openComposer()
val thumbnail = ByteArray(12).also {
it[0] = 0x89.toByte(); it[1] = 'P'.code.toByte(); it[2] = 'N'.code.toByte(); it[3] = 'G'.code.toByte()
}
val selected = PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL, thumbnail, isTemporaryCopy = true)
viewModel.onFilesPicked(listOf(selected))
viewModel.setAccessPolicy(ShareAccessPolicy.AnyoneWithTransfer)
viewModel.createShare()
advanceUntilIdle()
assertFalse(viewModel.state.value.isComposerOpen)
assertEquals(null, viewModel.state.value.selectedFile)
assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy)
assertEquals(7UL, core.state.value.transfers.first().transferId)
assertEquals(7UL, viewModel.state.value.selectedTransferId)
assertEquals(TransferDetailPanel.Share, viewModel.state.value.detailPanel)
assertContentEquals(thumbnail, previews.previews.value.getValue(7UL))
assertEquals(listOf(selected), fileSystem.discardedPickedFiles)
}
@Test
fun sendViewModelStopsSharingFromCatalogAction() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
@@ -559,8 +508,6 @@ class ViewModelsTest {
}
val viewModel = SendViewModel(
core,
FakeFileSystemService(folder),
preferences(),
FakeFilePreviewRepository(),
UiMessageController(),
)
@@ -572,63 +519,6 @@ class ViewModelsTest {
assertEquals(listOf(7UL), core.cancelledTransfers)
}
@Test
fun sendComposerStaysOpenWhenShareCreationFails() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply { mutableState.value = CoreState(isInitialized = true) }
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
advanceUntilIdle()
viewModel.openComposer()
viewModel.onFilesPicked(listOf(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL)))
viewModel.createShare()
advanceUntilIdle()
assertTrue(viewModel.state.value.isComposerOpen)
assertEquals("photo.jpg", viewModel.state.value.selectedFile?.displayName)
assertFalse(viewModel.state.value.isSharing)
}
@Test
fun sendViewModelSupportsMultipleFilesAndDefaultName() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = CoreState(isInitialized = true)
shareResult = Result.success(Share(9UL, "ticket", "2 files", "hash", 2UL, 84UL))
}
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
advanceUntilIdle()
viewModel.onFilesPicked(
listOf(
com.vnidrop.app.core.PickedShareFile("/tmp/a.jpg", "a.jpg", 40UL),
com.vnidrop.app.core.PickedShareFile("/tmp/b.jpg", "b.jpg", 44UL),
),
)
assertEquals("2 files", viewModel.state.value.transferName)
assertEquals(2, viewModel.state.value.selectedFiles.size)
viewModel.createShare()
advanceUntilIdle()
assertEquals(2, core.lastShareSourceCount)
assertTrue(viewModel.state.value.selectedFiles.isEmpty())
}
@Test
fun sendViewModelNamesFolderSelectionAfterFolderDisplayName() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
advanceUntilIdle()
viewModel.onFilesPicked(
listOf(
com.vnidrop.app.core.PickedShareFile(
value = "/tmp/photos",
displayName = "photos",
isDirectory = true,
),
),
)
assertEquals("photos", viewModel.state.value.transferName)
assertTrue(viewModel.state.value.selectedFiles.single().isDirectory)
}
@Test
fun sendDeletionRemovesCoreTransferAndOwnedPreview() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
@@ -646,7 +536,7 @@ class ViewModelsTest {
}
val previews = FakeFilePreviewRepository()
previews.save(7UL, byteArrayOf(1, 2, 3))
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), previews, UiMessageController())
val viewModel = SendViewModel(core, previews, UiMessageController())
advanceUntilIdle()
viewModel.openTransfer(7UL)
viewModel.requestDeleteTransfer()

View File

@@ -3,13 +3,8 @@ package com.vnidrop.app.feature.saveddevices
import com.vnidrop.app.core.DeviceRelationshipModel
import com.vnidrop.app.core.DeviceRelationshipStateModel
import com.vnidrop.app.core.PairingEligibilityModel
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.preferences.AppPreferences
import com.vnidrop.app.support.FakeCoreGateway
import com.vnidrop.app.support.FakePreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
@@ -23,25 +18,9 @@ import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class PairingPromptCoordinatorTest {
@Test
fun experimentalOffDoesNotPromptOnEligibility() = runTest {
val core = initializedCore().apply {
pairingEligibilities = listOf(eligibility("peer-a"))
}
val coordinator = PairingPromptCoordinator(
core,
preferences(enabled = false),
UiMessageController(),
backgroundScope,
)
runCurrent()
advanceUntilIdle()
assertNull(coordinator.state.value.prompt)
}
@Test
fun waitsForCoreInitializeBeforeRefreshing() = runTest {
// Regression: experimental prefs emit before AppViewModel initialize finishes.
// The coordinator must not query domain state before AppViewModel initializes the core.
val core = FakeCoreGateway().apply {
pairingEligibilities = listOf(eligibility("peer-a"))
}
@@ -52,7 +31,6 @@ class PairingPromptCoordinatorTest {
}
val coordinator = PairingPromptCoordinator(
core,
preferences(enabled = true),
messages,
backgroundScope,
)
@@ -66,7 +44,7 @@ class PairingPromptCoordinatorTest {
runCurrent()
advanceUntilIdle()
assertEquals(1, core.listDeviceRelationshipsCount)
assertEquals(PairingPrompt.Eligibility("peer-a"), coordinator.state.value.prompt)
assertEquals(PairingPrompt.Eligibility("peer-a", "Remote device"), coordinator.state.value.prompt)
assertTrue(seen.isEmpty())
}
@@ -77,13 +55,12 @@ class PairingPromptCoordinatorTest {
}
val coordinator = PairingPromptCoordinator(
core,
preferences(enabled = true),
UiMessageController(),
backgroundScope,
)
runCurrent()
advanceUntilIdle()
assertEquals(PairingPrompt.Eligibility("peer-a"), coordinator.state.value.prompt)
assertEquals(PairingPrompt.Eligibility("peer-a", "Remote device"), coordinator.state.value.prompt)
coordinator.accept()
runCurrent()
@@ -98,7 +75,6 @@ class PairingPromptCoordinatorTest {
}
val coordinator = PairingPromptCoordinator(
core,
preferences(enabled = true),
UiMessageController(),
backgroundScope,
)
@@ -119,7 +95,6 @@ class PairingPromptCoordinatorTest {
}
val coordinator = PairingPromptCoordinator(
core,
preferences(enabled = true),
UiMessageController(),
backgroundScope,
)
@@ -139,7 +114,6 @@ class PairingPromptCoordinatorTest {
}
val coordinator = PairingPromptCoordinator(
core,
preferences(enabled = true),
UiMessageController(),
backgroundScope,
)
@@ -159,6 +133,7 @@ class PairingPromptCoordinatorTest {
private fun eligibility(peer: String) = PairingEligibilityModel(
peerEndpointId = peer,
remoteDisplayName = "Remote device",
sessionId = "session",
protocolVersion = 1u,
createdAt = 1L,
@@ -173,14 +148,4 @@ class PairingPromptCoordinatorTest {
createdAt = 1L,
updatedAt = 1L,
)
private fun preferences(enabled: Boolean) = FakePreferencesRepository(
AppPreferences(
username = "User",
receiveFolder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp"),
themeMode = ThemeMode.System,
notificationsEnabled = false,
experimentalSavedDevicesEnabled = enabled,
),
)
}

View File

@@ -1,17 +1,8 @@
package com.vnidrop.app.feature.saveddevices
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.core.SavedDeviceModel
import com.vnidrop.app.core.TargetedTransferModel
import com.vnidrop.app.core.TargetedTransferStateModel
import com.vnidrop.app.preferences.AppPreferences
import com.vnidrop.app.support.FakeCoreGateway
import com.vnidrop.app.support.FakeFileSystemService
import com.vnidrop.app.support.FakePreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
@@ -33,6 +24,23 @@ class SavedDevicesViewModelTest {
Dispatchers.resetMain()
}
@Test
fun loadsSavedDevicesWithoutAnExperimentalPreferenceGate() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = mutableState.value.copy(isInitialized = true)
savedDevices = listOf(device("peer-always-visible", label = null))
}
val viewModel = SavedDevicesViewModel(core, UiMessageController())
runCurrent()
advanceUntilIdle()
assertEquals("peer-always-visible", viewModel.state.value.savedDevices.single().endpointId)
assertEquals(false, viewModel.state.value.isLoading)
assertEquals(false, viewModel.state.value.loadFailed)
}
@Test
fun labelForgetAndBlockUpdateGateway() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
@@ -40,11 +48,8 @@ class SavedDevicesViewModelTest {
mutableState.value = mutableState.value.copy(isInitialized = true)
savedDevices = listOf(device("peer-1", label = null))
}
val preferences = preferences(enabled = true)
val viewModel = SavedDevicesViewModel(
core,
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
preferences,
UiMessageController(),
)
runCurrent()
@@ -79,46 +84,6 @@ class SavedDevicesViewModelTest {
assertEquals(listOf("peer-2"), core.blockedPeers.toList())
}
@Test
fun sendFromSavedDeviceCreatesTargetedTransfer() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = mutableState.value.copy(isInitialized = true)
savedDevices = listOf(device("peer-3", label = "Kitchen"))
createTargetedResult = Result.success(
TargetedTransferModel(
id = "t1",
senderEndpointId = "me",
receiverEndpointId = "peer-3",
manifestId = "m",
fileCount = 1u,
totalSize = 1u,
verifiedBytes = 0u,
state = TargetedTransferStateModel.Offering,
createdAt = 1L,
updatedAt = 1L,
),
)
}
val viewModel = SavedDevicesViewModel(
core,
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
preferences(enabled = true),
UiMessageController(),
)
runCurrent()
advanceUntilIdle()
viewModel.startSend("peer-3")
viewModel.onFilesPicked(
listOf(PickedShareFile(value = "/tmp/a.txt", displayName = "a.txt", sizeBytes = 1u)),
)
runCurrent()
advanceUntilIdle()
assertEquals(1, core.createdTargetedTransfers.size)
assertEquals("peer-3", core.createdTargetedTransfers.single().first)
assertNull(viewModel.state.value.sendTargetPeerId)
}
private fun device(id: String, label: String?) = SavedDeviceModel(
endpointId = id,
localLabel = label,
@@ -126,14 +91,4 @@ class SavedDevicesViewModelTest {
createdAt = 1L,
lastAuthenticatedAt = null,
)
private fun preferences(enabled: Boolean) = FakePreferencesRepository(
AppPreferences(
username = "User",
receiveFolder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp"),
themeMode = ThemeMode.System,
notificationsEnabled = false,
experimentalSavedDevicesEnabled = enabled,
),
)
}

View File

@@ -3,6 +3,7 @@ package com.vnidrop.app.feature.saveddevices
import com.vnidrop.app.core.PendingTargetedOfferModel
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.core.SavedDeviceModel
import com.vnidrop.app.core.TargetedOfferResponseModel
import com.vnidrop.app.preferences.AppPreferences
import com.vnidrop.app.support.FakeCoreGateway
@@ -21,6 +22,34 @@ import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class TargetedOfferCoordinatorTest {
@Test
fun pendingOfferUsesTheSavedDevicesDisplayNamePolicy() = runTest {
val core = initializedCore().apply {
pendingTargetedOffers = listOf(offer("named-transfer"))
savedDevices = listOf(
SavedDeviceModel(
endpointId = "sender",
localLabel = "Office PC",
remoteDisplayName = "Amira's laptop",
createdAt = 1,
lastAuthenticatedAt = 2,
),
)
}
val coordinator = TargetedOfferCoordinator(
core,
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
preferences(),
UiMessageController(),
backgroundScope,
)
runCurrent()
advanceUntilIdle()
assertEquals("Office PC", coordinator.state.value.currentSenderDisplayName)
}
@Test
fun acceptApprovesAndPullsByTransferId() = runTest {
val core = initializedCore().apply {
@@ -31,7 +60,7 @@ class TargetedOfferCoordinatorTest {
val coordinator = TargetedOfferCoordinator(
core,
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
preferences(enabled = true),
preferences(),
UiMessageController(),
backgroundScope,
)
@@ -67,7 +96,7 @@ class TargetedOfferCoordinatorTest {
ReceiveFolder(ReceiveFolderKind.AndroidPublicDownloads, "downloads", "Downloads"),
receiveOutputSink = sink,
),
preferences(enabled = true),
preferences(),
UiMessageController(),
backgroundScope,
)
@@ -89,7 +118,7 @@ class TargetedOfferCoordinatorTest {
val coordinator = TargetedOfferCoordinator(
core,
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
preferences(enabled = true),
preferences(),
UiMessageController(),
backgroundScope,
)
@@ -102,23 +131,6 @@ class TargetedOfferCoordinatorTest {
assertTrue(core.receivedTargetedTransferIds.isEmpty())
}
@Test
fun experimentalOffIgnoresPendingOffers() = runTest {
val core = initializedCore().apply {
pendingTargetedOffers = listOf(offer("transfer-3"))
}
val coordinator = TargetedOfferCoordinator(
core,
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
preferences(enabled = false),
UiMessageController(),
backgroundScope,
)
runCurrent()
advanceUntilIdle()
assertTrue(coordinator.state.value.pending.isEmpty())
}
@Test
fun waitsForCoreInitializeBeforeListingOffers() = runTest {
val core = FakeCoreGateway().apply {
@@ -127,7 +139,7 @@ class TargetedOfferCoordinatorTest {
val coordinator = TargetedOfferCoordinator(
core,
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
preferences(enabled = true),
preferences(),
UiMessageController(),
backgroundScope,
)
@@ -160,13 +172,12 @@ class TargetedOfferCoordinatorTest {
receivedAt = 1L,
)
private fun preferences(enabled: Boolean) = FakePreferencesRepository(
private fun preferences() = FakePreferencesRepository(
AppPreferences(
username = "User",
receiveFolder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp"),
themeMode = ThemeMode.System,
notificationsEnabled = false,
experimentalSavedDevicesEnabled = enabled,
),
)
}

View File

@@ -0,0 +1,249 @@
package com.vnidrop.app.feature.send
import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.SavedDeviceModel
import com.vnidrop.app.core.Share
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.TargetedTransferModel
import com.vnidrop.app.core.TargetedTransferStateModel
import com.vnidrop.app.support.FakeCoreGateway
import com.vnidrop.app.support.FakeFilePreviewRepository
import com.vnidrop.app.support.FakePickedShareSourceAdapter
import com.vnidrop.app.ui.feedback.UiMessageController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class TransferDraftViewModelTest {
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun invitationAndTargetedCreationShareOneDraftBehavior() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = readyCore().apply {
shareResult = Result.success(Share(7UL, "ticket", "Holiday", "hash", 2UL, 3UL))
createTargetedResult = Result.success(targeted("target-9", "peer-9"))
savedDevices = listOf(device("peer-9", "Kitchen"))
}
val adapter = FakePickedShareSourceAdapter()
val invitation = draft(core, adapter)
invitation.openInvitation("Alice")
invitation.chooseFiles()
advanceUntilIdle()
assertIs<TransferDraftEffect.OpenPicker>(invitation.effectFlow.first())
val invitationRequest = invitation.state.value.pickerRequestId!!
invitation.onFilesPicked(
invitationRequest,
listOf(file("/a", "a.txt"), file("/b", "b.txt")),
)
advanceUntilIdle()
assertEquals("2 localized files", invitation.state.value.transferName)
invitation.changeTransferName("Holiday")
invitation.changeAccessPolicy(ShareAccessPolicy.AnyoneWithTransfer)
invitation.submit()
advanceUntilIdle()
val invitationCreated = assertIs<TransferDraftEffect.Created>(invitation.effectFlow.first()).creation
assertIs<TransferDraftCreation.Invitation>(invitationCreated)
assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy)
assertFalse(invitation.state.value.isOpen)
val targeted = draft(core, adapter)
targeted.openTargeted(core.savedDevices.single(), "Saved device")
targeted.chooseFolder()
advanceUntilIdle()
assertIs<TransferDraftEffect.OpenPicker>(targeted.effectFlow.first())
val targetedRequest = targeted.state.value.pickerRequestId!!
targeted.onFilesPicked(
targetedRequest,
listOf(file("/photos", "Photos", directory = true)),
)
advanceUntilIdle()
assertEquals("Photos", targeted.state.value.transferName)
assertEquals(
"Kitchen",
assertIs<TransferDraftDestination.Targeted>(targeted.state.value.destination).receiver.displayName,
)
targeted.submit()
advanceUntilIdle()
val created = assertIs<TransferDraftEffect.Created>(targeted.effectFlow.first()).creation
assertEquals(TransferDraftCreation.Targeted("target-9", "peer-9"), created)
assertTrue(core.createdTargetedTransfers.single().second.single().isDirectory)
}
@Test
fun replacementAndDismissalReleaseOnlyOwnedCopiesExactlyOnce() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val adapter = FakePickedShareSourceAdapter()
val viewModel = draft(readyCore(), adapter)
val first = file("/owned-a", "a.txt", temporary = true)
val second = file("/owned-b", "b.txt", temporary = true)
viewModel.openInvitation("Alice")
viewModel.chooseFiles()
viewModel.onFilesPicked(viewModel.state.value.pickerRequestId!!, listOf(first))
advanceUntilIdle()
viewModel.chooseFiles()
viewModel.onFilesPicked(viewModel.state.value.pickerRequestId!!, listOf(second))
advanceUntilIdle()
viewModel.dismiss()
advanceUntilIdle()
assertEquals(listOf(first, second), adapter.discardedPickedFiles)
}
@Test
fun failedCreationPreservesEditableDraftAndOwnedSources() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = readyCore()
val adapter = FakePickedShareSourceAdapter()
val viewModel = draft(core, adapter)
val selected = file("/owned", "report.pdf", temporary = true)
viewModel.openInvitation("Alice")
viewModel.chooseFiles()
viewModel.onFilesPicked(viewModel.state.value.pickerRequestId!!, listOf(selected))
advanceUntilIdle()
viewModel.changeTransferName("Report")
viewModel.submit()
advanceUntilIdle()
assertTrue(viewModel.state.value.isOpen)
assertFalse(viewModel.state.value.isSubmitting)
assertEquals("Report", viewModel.state.value.transferName)
assertEquals(emptyList(), adapter.discardedPickedFiles)
}
@Test
fun targetedSubmitRevalidatesReceiverWithoutFallingBackToInvitation() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = readyCore().apply { savedDevices = listOf(device("peer-3", "Desk")) }
val viewModel = draft(core, FakePickedShareSourceAdapter())
viewModel.openTargeted(core.savedDevices.single(), "Saved device")
viewModel.chooseFiles()
viewModel.onFilesPicked(viewModel.state.value.pickerRequestId!!, listOf(file("/a", "a.txt")))
advanceUntilIdle()
core.savedDevices = emptyList()
viewModel.submit()
advanceUntilIdle()
assertTrue(viewModel.state.value.isOpen)
assertIs<TransferDraftDestination.Targeted>(viewModel.state.value.destination)
assertTrue(core.createdTargetedTransfers.isEmpty())
}
@Test
fun stalePickerResultCannotReopenDismissedDraftAndIsReleased() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val adapter = FakePickedShareSourceAdapter()
val viewModel = draft(readyCore(), adapter)
val stale = file("/owned", "late.txt", temporary = true)
viewModel.openInvitation("Alice")
viewModel.chooseFiles()
val requestId = viewModel.state.value.pickerRequestId!!
viewModel.dismiss()
viewModel.onFilesPicked(requestId, listOf(stale))
advanceUntilIdle()
assertFalse(viewModel.state.value.isOpen)
assertEquals(listOf(stale), adapter.discardedPickedFiles)
}
@Test
fun pickerCancellationPreservesTheExistingDraft() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val viewModel = draft(readyCore(), FakePickedShareSourceAdapter())
viewModel.openInvitation("Alice")
viewModel.chooseFiles()
val firstRequest = viewModel.state.value.pickerRequestId!!
viewModel.onFilesPicked(firstRequest, listOf(file("/a", "a.txt")))
advanceUntilIdle()
viewModel.chooseFiles()
val cancelledRequest = viewModel.state.value.pickerRequestId!!
viewModel.onFilesPicked(cancelledRequest, emptyList())
assertEquals("a.txt", viewModel.state.value.transferName)
assertEquals(listOf("a.txt"), viewModel.state.value.sources.map(TransferDraftSource::displayName))
assertFalse(viewModel.state.value.isPicking)
}
@Test
fun removingAFileKeepsAUserEditedTransferName() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val viewModel = draft(readyCore(), FakePickedShareSourceAdapter())
viewModel.openInvitation("Alice")
viewModel.chooseFiles()
viewModel.onFilesPicked(
viewModel.state.value.pickerRequestId!!,
listOf(file("/a", "a.txt"), file("/b", "b.txt")),
)
advanceUntilIdle()
viewModel.changeTransferName("My documents")
viewModel.removeSource(viewModel.state.value.sources.first().id)
advanceUntilIdle()
assertEquals("My documents", viewModel.state.value.transferName)
assertEquals(listOf("b.txt"), viewModel.state.value.sources.map(TransferDraftSource::displayName))
}
private fun draft(core: FakeCoreGateway, adapter: FakePickedShareSourceAdapter) =
TransferDraftViewModel(
repository = core,
sourceAdapter = adapter,
filePreviewRepository = FakeFilePreviewRepository(),
messages = UiMessageController(),
multipleFilesName = { "$it localized files" },
)
private fun readyCore() = FakeCoreGateway().apply {
mutableState.value = CoreState(isInitialized = true)
}
private fun file(
value: String,
name: String,
directory: Boolean = false,
temporary: Boolean = false,
) = PickedShareFile(
value = value,
displayName = name,
sizeBytes = 1UL,
isTemporaryCopy = temporary,
isDirectory = directory,
)
private fun device(id: String, label: String?) = SavedDeviceModel(
endpointId = id,
localLabel = label,
remoteDisplayName = "Remote",
createdAt = 1L,
lastAuthenticatedAt = 1L,
)
private fun targeted(id: String, peerId: String) = TargetedTransferModel(
id = id,
senderEndpointId = "me",
receiverEndpointId = peerId,
manifestId = "manifest",
transferName = "Transfer",
fileCount = 1UL,
totalSize = 1UL,
verifiedBytes = 0UL,
state = TargetedTransferStateModel.Offering,
createdAt = 1L,
updatedAt = 1L,
)
}

View File

@@ -10,6 +10,7 @@ import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.core.PairingEligibilityModel
import com.vnidrop.app.core.PendingTargetedOfferModel
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.PickedShareSourceAdapter
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceivedArtifactModel
import com.vnidrop.app.core.ReceivedStorageInspection
@@ -334,9 +335,6 @@ class FakePreferencesRepository(
override suspend fun resetReceiveFolder() = Unit
override suspend fun setThemeMode(mode: ThemeMode) { mutablePreferences.value = mutablePreferences.value.copy(themeMode = mode) }
override suspend fun setNotificationsEnabled(enabled: Boolean) { mutablePreferences.value = mutablePreferences.value.copy(notificationsEnabled = enabled) }
override suspend fun setExperimentalSavedDevicesEnabled(enabled: Boolean) {
mutablePreferences.value = mutablePreferences.value.copy(experimentalSavedDevicesEnabled = enabled)
}
override suspend fun setRelaySettings(settings: RelaySettings) {
mutablePreferences.value = mutablePreferences.value.copy(relaySettings = settings)
}
@@ -378,7 +376,6 @@ class FakeFileSystemService(
var reclaimTemporaryStorageCount = 0
var revealFolderResult: Result<Unit> = Result.success(Unit)
val revealedFolders = mutableListOf<ReceiveFolder>()
val discardedPickedFiles = mutableListOf<PickedShareFile>()
override val supportsCustomReceiveFolders: Boolean get() = supportsCustomFolders
override fun defaultReceiveFolder() = folder
override fun effectiveReceiveFolder(configuredFolder: ReceiveFolder) =
@@ -397,42 +394,33 @@ class FakeFileSystemService(
revealedFolders += folder
return revealFolderResult
}
override suspend fun discardPickedFiles(files: List<PickedShareFile>) {
discardedPickedFiles += files
}
override suspend fun sharePickedFiles(
repository: CoreGateway,
}
internal class FakePickedShareSourceAdapter : PickedShareSourceAdapter {
val discardedPickedFiles = mutableListOf<PickedShareFile>()
var adaptResult: Result<Unit> = Result.success(Unit)
var beforeOperation: suspend () -> Unit = {}
override suspend fun <T> withShareSources(
files: List<PickedShareFile>,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> {
val sources = files.map { file ->
uniffi.vnidrop.ShareSource(
kind = uniffi.vnidrop.SourceKind.PATH,
value = file.value,
displayName = file.displayName,
isDirectory = false,
)
}
return repository.shareSources(sources, transferName, senderName, accessPolicy)
operation: suspend (List<uniffi.vnidrop.ShareSource>) -> T,
): Result<T> = runCatching {
adaptResult.getOrThrow()
beforeOperation()
operation(
files.map { file ->
uniffi.vnidrop.ShareSource(
kind = uniffi.vnidrop.SourceKind.PATH,
value = file.value,
displayName = file.displayName,
isDirectory = file.isDirectory,
)
},
)
}
override suspend fun createTargetedTransferFromPickedFiles(
repository: CoreGateway,
receiverEndpointId: String,
files: List<PickedShareFile>,
transferName: String?,
): Result<TargetedTransferModel> {
val sources = files.map { file ->
uniffi.vnidrop.ShareSource(
kind = uniffi.vnidrop.SourceKind.PATH,
value = file.value,
displayName = file.displayName,
isDirectory = false,
)
}
return repository.createTargetedTransfer(receiverEndpointId, sources, transferName)
override suspend fun discardPickedFiles(files: List<PickedShareFile>) {
discardedPickedFiles += files.filter(PickedShareFile::isTemporaryCopy)
}
}

View File

@@ -9,10 +9,10 @@ class NavigationModelTest {
@Test
fun primaryNavigationContainsOnlyProductDestinations() {
assertEquals(
listOf(AppDestination.Send, AppDestination.Receive, AppDestination.Settings),
listOf(AppDestination.Send, AppDestination.Receive, AppDestination.SavedDevices, AppDestination.Settings),
primaryNavigationItems.map { it.destination },
)
assertEquals(3, primaryNavigationItems.map { it.label }.distinct().size)
assertEquals(4, primaryNavigationItems.map { it.label }.distinct().size)
}
@Test

View File

@@ -1,11 +1,13 @@
package com.vnidrop.app.ui.state
import com.vnidrop.app.feature.receive.ReceiveState
import com.vnidrop.app.feature.send.SendState
import com.vnidrop.app.feature.send.TransferDraftDestination
import com.vnidrop.app.feature.send.TransferDraftSource
import com.vnidrop.app.feature.send.TransferDraftState
import com.vnidrop.app.feature.send.DraftSourceId
import com.vnidrop.app.core.CoreEventModel
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.ui.theme.ThemeMode
@@ -52,16 +54,17 @@ class AppUiModelsTest {
}
@Test
fun sendStateExposesShareEligibility() {
val ready = SendState(
selectedFiles = listOf(PickedShareFile("/tmp/payload.txt", "payload.txt", 128UL)),
fun transferDraftStateExposesSubmitEligibility() {
val ready = TransferDraftState(
destination = TransferDraftDestination.Invitation,
sources = listOf(TransferDraftSource(DraftSourceId("source-1"), "payload.txt", 128UL, null, false)),
transferName = "payload.txt",
)
assertTrue(ready.canCreateShare(coreInitialized = true))
assertFalse(ready.canCreateShare(coreInitialized = false))
assertFalse(SendState().canCreateShare(coreInitialized = true))
assertFalse(ready.copy(isSharing = true).canCreateShare(coreInitialized = true))
assertTrue(ready.canSubmit(coreInitialized = true))
assertFalse(ready.canSubmit(coreInitialized = false))
assertFalse(TransferDraftState().canSubmit(coreInitialized = true))
assertFalse(ready.copy(isSubmitting = true).canSubmit(coreInitialized = true))
}
@Test

View File

@@ -36,7 +36,7 @@ actual fun rememberShareFilePicker(
JvmFilePickerBackend.XdgPortal -> scope.launch {
try {
val selected = withContext(Dispatchers.IO) { pickShareFilesWithPortal() }
if (selected.isNotEmpty()) onFilesPicked(selected)
onFilesPicked(selected)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
@@ -48,7 +48,7 @@ actual fun rememberShareFilePicker(
scope.launch {
try {
val selected = withContext(Dispatchers.IO) { pickWindowsFiles(owner) }
if (selected.isNotEmpty()) onFilesPicked(selected)
onFilesPicked(selected)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
@@ -58,7 +58,7 @@ actual fun rememberShareFilePicker(
}
JvmFilePickerBackend.AwtSwing -> openPicker(onError) {
val selected = pickShareFiles()
if (selected.isNotEmpty()) onFilesPicked(selected)
onFilesPicked(selected)
}
}
}
@@ -69,8 +69,8 @@ actual fun rememberShareFilePicker(
try {
val selected = withContext(Dispatchers.IO) {
pickDirectoryWithPortal("Select folder to share")?.toPickedShareFile(isDirectory = true)
} ?: return@launch
onFilesPicked(listOf(selected))
}
onFilesPicked(selected?.let(::listOf).orEmpty())
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
@@ -83,8 +83,8 @@ actual fun rememberShareFilePicker(
try {
val selected = withContext(Dispatchers.IO) {
pickWindowsFolder("Select folder to share", owner)
} ?: return@launch
onFilesPicked(listOf(selected.toPickedShareFile(isDirectory = true)))
}
onFilesPicked(selected?.let { listOf(it.toPickedShareFile(isDirectory = true)) }.orEmpty())
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
@@ -93,8 +93,8 @@ actual fun rememberShareFilePicker(
}
}
JvmFilePickerBackend.AwtSwing -> openPicker(onError) {
val selected = pickDirectory(title = "Select folder to share") ?: return@openPicker
onFilesPicked(listOf(selected.toPickedShareFile(isDirectory = true)))
val selected = pickDirectory(title = "Select folder to share")
onFilesPicked(selected?.let { listOf(it.toPickedShareFile(isDirectory = true)) }.orEmpty())
}
}
}

View File

@@ -53,35 +53,7 @@ private class JvmFileSystemService : FileSystemService {
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = null
override suspend fun sharePickedFiles(
repository: CoreGateway,
files: List<PickedShareFile>,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> {
require(files.isNotEmpty()) { "Select at least one file to share" }
return repository.shareSources(pathShareSources(files), transferName, senderName, accessPolicy)
}
override suspend fun createTargetedTransferFromPickedFiles(
repository: CoreGateway,
receiverEndpointId: String,
files: List<PickedShareFile>,
transferName: String?,
): Result<TargetedTransferModel> {
require(files.isNotEmpty()) { "Select at least one file to share" }
return repository.createTargetedTransfer(receiverEndpointId, pathShareSources(files), transferName)
}
private fun pathShareSources(files: List<PickedShareFile>) = files.map { file ->
uniffi.vnidrop.ShareSource(
kind = uniffi.vnidrop.SourceKind.PATH,
value = file.value,
displayName = file.displayName,
isDirectory = file.isDirectory || File(file.value).isDirectory,
)
}
}
internal fun desktopTemporaryUsage(receiveFolder: ReceiveFolder): ULong {

View File

@@ -0,0 +1,35 @@
package com.vnidrop.app.core
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import java.io.File
@Composable
internal actual fun rememberPickedShareSourceAdapter(): PickedShareSourceAdapter =
remember { JvmPickedShareSourceAdapter() }
internal class JvmPickedShareSourceAdapter : PickedShareSourceAdapter {
override suspend fun <T> withShareSources(
files: List<PickedShareFile>,
operation: suspend (List<uniffi.vnidrop.ShareSource>) -> T,
): Result<T> = runCatching {
require(files.isNotEmpty()) { "Select at least one file to share" }
operation(pathShareSources(files))
}
override suspend fun discardPickedFiles(files: List<PickedShareFile>) {
files.filter(PickedShareFile::isTemporaryCopy).forEach { file ->
val copy = File(file.value)
check(!copy.exists() || copy.delete()) { "Could not discard app-owned picker copy" }
}
}
private fun pathShareSources(files: List<PickedShareFile>) = files.map { file ->
uniffi.vnidrop.ShareSource(
kind = uniffi.vnidrop.SourceKind.PATH,
value = file.value,
displayName = file.displayName,
isDirectory = file.isDirectory || File(file.value).isDirectory,
)
}
}

View File

@@ -7,8 +7,50 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import uniffi.vnidrop.SourceKind
class FileSystemServiceTest {
@Test
fun desktopShareAdapterPreservesDirectorySourcesForRustTraversal() = kotlinx.coroutines.test.runTest {
val directory = createTempDirectory("vnidrop-share-adapter")
try {
val adapter = JvmPickedShareSourceAdapter()
val result = adapter.withShareSources(
listOf(PickedShareFile(directory.toString(), "Photos", isDirectory = true)),
) { sources -> sources }
val source = result.getOrThrow().single()
assertEquals(SourceKind.PATH, source.kind)
assertEquals(directory.toString(), source.value)
assertTrue(source.isDirectory)
} finally {
directory.toFile().deleteRecursively()
}
}
@Test
fun desktopShareAdapterDeletesOnlyExplicitAppOwnedCopies() = kotlinx.coroutines.test.runTest {
val root = createTempDirectory("vnidrop-share-cleanup")
try {
val owned = root.resolve("owned.tmp")
val original = root.resolve("original.txt")
Files.writeString(owned, "owned")
Files.writeString(original, "original")
JvmPickedShareSourceAdapter().discardPickedFiles(
listOf(
PickedShareFile(owned.toString(), "owned.tmp", isTemporaryCopy = true),
PickedShareFile(original.toString(), "original.txt"),
),
)
assertFalse(Files.exists(owned))
assertTrue(Files.exists(original))
} finally {
root.toFile().deleteRecursively()
}
}
@Test
fun desktopTemporaryUsageCountsOnlyVnidropPartFiles() {
val root = createTempDirectory("vnidrop-temporary-usage")

View File

@@ -0,0 +1,179 @@
package com.vnidrop.app.feature.saveddevices
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runComposeUiTest
import com.vnidrop.app.UiPlatform
import com.vnidrop.app.core.DeviceRelationshipModel
import com.vnidrop.app.core.DeviceRelationshipStateModel
import com.vnidrop.app.core.PairingEligibilityModel
import com.vnidrop.app.core.SavedDeviceModel
import com.vnidrop.app.ui.platform.LocalUiPlatform
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.theme.VniDropTheme
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlinx.coroutines.runBlocking
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_retry
import vnidrop.shared.generated.resources.saved_devices_block_action
import vnidrop.shared.generated.resources.saved_devices_block_confirm_title
import vnidrop.shared.generated.resources.saved_devices_empty
import vnidrop.shared.generated.resources.saved_devices_forget_action
import vnidrop.shared.generated.resources.saved_devices_load_failed
import vnidrop.shared.generated.resources.saved_devices_send_action
@OptIn(ExperimentalTestApi::class)
class SavedDevicesScreenTest {
@Test
fun emptyLoadingAndErrorStatesAreActionable() = runComposeUiTest {
var retried = 0
val state = mutableStateOf(SavedDevicesState(isLoading = true))
setContent {
VniDropTheme(isDarkTheme = false) {
SavedDevicesScreen(state.value, WindowClass.Phone, onRetry = { retried += 1 })
}
}
onAllNodesWithText(Res.string.saved_devices_empty.value).assertCountEquals(0)
runOnIdle { state.value = SavedDevicesState(isLoading = false, loadFailed = true) }
onNodeWithText(Res.string.saved_devices_load_failed.value).assertIsDisplayed()
onNodeWithText(Res.string.button_retry.value).performClick()
runOnIdle { assertEquals(1, retried) }
runOnIdle { state.value = SavedDevicesState(isLoading = false) }
onNodeWithText(Res.string.saved_devices_empty.value).assertIsDisplayed()
}
@Test
fun authenticatedNamesAndLocalLabelsArePrimaryWhileEndpointIsSecondary() = runComposeUiTest {
setContent {
CompositionLocalProvider(LocalUiPlatform provides UiPlatform.Windows) {
VniDropTheme(isDarkTheme = false) {
SavedDevicesScreen(
state = SavedDevicesState(
isLoading = false,
eligibilities = listOf(eligibility("eligible-peer", "Pixel 9")),
pendingRelationships = listOf(incoming("eligible-peer")),
savedDevices = listOf(device("saved-peer-long-identifier", "Office laptop", "Amira's PC")),
),
windowClass = WindowClass.Desktop,
)
}
}
}
onNodeWithText("Office laptop").assertIsDisplayed()
onNodeWithText("Remote name: Amira's PC").assertIsDisplayed()
onAllNodesWithText("Pixel 9", useUnmergedTree = true).assertCountEquals(2)
onNodeWithText("Device ID: saved-peer-long-…").assertIsDisplayed()
}
@Test
fun busyDeviceDisablesSendAndDestructiveActionRequiresConfirmation() = runComposeUiTest {
var blocked = 0
val device = device("peer-one", null, "Riley's phone")
val state = mutableStateOf(SavedDevicesState(isLoading = false, savedDevices = listOf(device), busyPeerIds = setOf(device.endpointId)))
setContent {
CompositionLocalProvider(LocalUiPlatform provides UiPlatform.Android) {
VniDropTheme(isDarkTheme = false) {
SavedDevicesScreen(
state = state.value,
windowClass = WindowClass.Phone,
onBlock = { blocked += 1 },
)
}
}
}
onNodeWithText(Res.string.saved_devices_send_action.value).assertIsNotEnabled()
runOnIdle { state.value = state.value.copy(busyPeerIds = emptySet()) }
onNodeWithContentDescription("More actions for Riley's phone").performClick()
onNodeWithText(Res.string.saved_devices_block_action.value).performClick()
onNodeWithText(Res.string.saved_devices_block_confirm_title.value).assertIsDisplayed()
runOnIdle { assertEquals(0, blocked) }
onNodeWithText(Res.string.saved_devices_block_action.value).performClick()
runOnIdle { assertEquals(1, blocked) }
}
@Test
fun forgetIsAvailableFromNativeOverflowMenu() = runComposeUiTest {
val device = device("peer-two", null, "Desktop")
setContent {
CompositionLocalProvider(LocalUiPlatform provides UiPlatform.Linux) {
VniDropTheme(isDarkTheme = false) {
SavedDevicesScreen(
state = SavedDevicesState(isLoading = false, savedDevices = listOf(device)),
windowClass = WindowClass.Desktop,
)
}
}
}
onNodeWithContentDescription("More actions for Desktop").performClick()
onNodeWithText(Res.string.saved_devices_forget_action.value).assertIsDisplayed()
}
private fun device(endpoint: String, label: String?, name: String?) = SavedDeviceModel(
endpointId = endpoint,
localLabel = label,
remoteDisplayName = name,
createdAt = 1,
lastAuthenticatedAt = 2,
)
private fun eligibility(endpoint: String, name: String) = PairingEligibilityModel(
peerEndpointId = endpoint,
remoteDisplayName = name,
sessionId = "session",
protocolVersion = 1u,
createdAt = 1,
expiresAt = 2,
)
private fun incoming(endpoint: String) = DeviceRelationshipModel(
remoteEndpointId = endpoint,
state = DeviceRelationshipStateModel.PendingIncoming,
generation = 1u,
minimumProtocolVersion = 1u,
createdAt = 1,
updatedAt = 2,
)
}
@Composable
private fun SavedDevicesScreen(
state: SavedDevicesState,
windowClass: WindowClass,
onRetry: () -> Unit = {},
onBlock: (String) -> Unit = {},
) = SavedDevicesScreen(
state = state,
windowClass = windowClass,
onRetry = onRetry,
onRememberEligible = {},
onDeclineEligible = {},
onAcceptIncoming = {},
onDeclineIncoming = {},
onSend = {},
onOpenLabel = {},
onForget = {},
onBlock = onBlock,
onLabelDraftChanged = {},
onSaveLabel = {},
onClearLabel = {},
onDismissLabel = {},
)
private val StringResource.value: String
get() = runBlocking { getString(this@value) }

View File

@@ -129,15 +129,6 @@ class AppPreferencesRepositoryTest {
assertEquals(true, repository.preferences.first().notificationsEnabled)
}
@Test
fun experimentalSavedDevicesOptInIsDisabledByDefaultAndPersisted() = runBlocking {
val repository = repositoryForTest()
assertEquals(false, repository.preferences.first().experimentalSavedDevicesEnabled)
repository.setExperimentalSavedDevicesEnabled(true)
assertEquals(true, repository.preferences.first().experimentalSavedDevicesEnabled)
}
@Test
fun legacyAndroidAppDownloadsPathIsPromotedToDefault() = runBlocking {
val publicDefault = ReceiveFolder(

View File

@@ -44,10 +44,14 @@ import com.vnidrop.app.feature.settings.StorageBreakdown
import com.vnidrop.app.feature.settings.SettingsOverview
import com.vnidrop.app.feature.send.SendScreen
import com.vnidrop.app.feature.send.SendState
import com.vnidrop.app.feature.send.DraftSourceId
import com.vnidrop.app.feature.send.TransferComposer
import com.vnidrop.app.feature.send.TransferDraftDestination
import com.vnidrop.app.feature.send.TransferDraftSource
import com.vnidrop.app.feature.send.TransferDraftState
import com.vnidrop.app.feature.send.TransferCatalog
import com.vnidrop.app.UiPlatform
import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferDirection
@@ -84,8 +88,8 @@ import vnidrop.shared.generated.resources.button_create_new_transfer
import vnidrop.shared.generated.resources.button_download_invitation
import vnidrop.shared.generated.resources.button_open_settings
import vnidrop.shared.generated.resources.button_receive_files
import vnidrop.shared.generated.resources.experimental_settings_title
import vnidrop.shared.generated.resources.nav_receive
import vnidrop.shared.generated.resources.nav_saved_devices
import vnidrop.shared.generated.resources.nav_send
import vnidrop.shared.generated.resources.notifications_description
import vnidrop.shared.generated.resources.notifications_local_title
@@ -398,60 +402,6 @@ class FoundationComposeTest {
runOnIdle { assertTrue(opened) }
}
@Test
fun desktopSettingsShowsExperimentalWhenGateEnabled() = runComposeUiTest {
setContent {
VniDropTheme(isDarkTheme = false) {
SettingsScreen(
state = SettingsState(),
windowClass = WindowClass.Desktop,
showExperimental = true,
onSectionSelected = {},
onUsernameChanged = {},
onThemeModeChanged = {},
onChooseFolder = {},
onResetFolder = {},
onNotificationsChanged = {},
onOpenNotificationSettings = {},
onBugWhatChanged = {},
onBugExpectedChanged = {},
onBugStepsChanged = {},
onBugContactChanged = {},
onBugIncludeLogsChanged = {},
onSubmitBugReport = {},
)
}
}
onNodeWithText(Res.string.experimental_settings_title.value).assertIsDisplayed()
}
@Test
fun unsupportedDesktopHostHidesExperimentalSection() = runComposeUiTest {
setContent {
VniDropTheme(isDarkTheme = false) {
SettingsScreen(
state = SettingsState(),
windowClass = WindowClass.Desktop,
showExperimental = false,
onSectionSelected = {},
onUsernameChanged = {},
onThemeModeChanged = {},
onChooseFolder = {},
onResetFolder = {},
onNotificationsChanged = {},
onOpenNotificationSettings = {},
onBugWhatChanged = {},
onBugExpectedChanged = {},
onBugStepsChanged = {},
onBugContactChanged = {},
onBugIncludeLogsChanged = {},
onSubmitBugReport = {},
)
}
}
onAllNodesWithText(Res.string.experimental_settings_title.value).assertCountEquals(0)
}
@Test
fun snackbarDisplaysBufferedMessage() = runComposeUiTest {
val controller = UiMessageController()
@@ -514,6 +464,26 @@ class FoundationComposeTest {
assertTrue(overlayBottom <= navigationLabelTop)
}
@Test
fun androidBottomNavigationPromotesSavedDevicesAsAProductDestination() = runComposeUiTest {
var selected = AppDestination.Send
setContent {
VniDropTheme(isDarkTheme = false) {
AppShell(
selectedDestination = selected,
windowClass = WindowClass.Phone,
uiPlatform = UiPlatform.Android,
onDestinationSelected = { selected = it },
) {
Text("Content")
}
}
}
onNodeWithText(Res.string.nav_saved_devices.value).assertIsDisplayed().performClick()
runOnIdle { assertEquals(AppDestination.SavedDevices, selected) }
}
@Test
fun narrowDesktopWindowKeepsDesktopSourceListNavigation() = runComposeUiTest {
var selected = AppDestination.Send
@@ -657,30 +627,25 @@ class FoundationComposeTest {
}
@Test
fun phoneSendEmptyStateOpensCreationDrawer() = runComposeUiTest {
val state = mutableStateOf(SendState())
fun phoneTransferComposerShowsSourceChoices() = runComposeUiTest {
setContent {
VniDropTheme(isDarkTheme = false) {
SendScreen(
coreState = CoreState(isInitialized = true),
state = state.value,
TransferComposer(
coreInitialized = true,
state = TransferDraftState(destination = TransferDraftDestination.Invitation),
windowClass = WindowClass.Phone,
onOpenComposer = { state.value = state.value.copy(isComposerOpen = true) },
onDismissComposer = {},
onSelectFile = {},
onSelectFolder = {},
onClearFile = {},
onRemoveFile = {},
onTransferNameChanged = {},
onSenderNameChanged = {},
onAccessPolicyChanged = {},
onCreateShare = {},
onTransferSelected = {},
onCloseTransferDetails = {},
onCopyTicket = {},
onSubmit = {},
)
}
}
onNodeWithText(Res.string.button_create_new_transfer.value).performClick()
onNodeWithText(Res.string.send_choose_file_title.value).assertIsDisplayed()
onNodeWithText(Res.string.button_choose_files.value).assertIsDisplayed()
}
@@ -690,26 +655,23 @@ class FoundationComposeTest {
var selectedPolicy: ShareAccessPolicy? = null
setContent {
VniDropTheme(isDarkTheme = false) {
SendScreen(
coreState = CoreState(isInitialized = true),
state = SendState(
isComposerOpen = true,
selectedFiles = listOf(PickedShareFile("/tmp/photos.zip", "photos.zip", 1536UL)),
TransferComposer(
coreInitialized = true,
state = TransferDraftState(
destination = TransferDraftDestination.Invitation,
sources = listOf(TransferDraftSource(DraftSourceId("source-1"), "photos.zip", 1536UL, null, false)),
transferName = "photos.zip",
senderName = "Sender",
),
windowClass = WindowClass.Desktop,
onOpenComposer = {},
onDismissComposer = {},
onSelectFile = {},
onSelectFolder = {},
onClearFile = {},
onRemoveFile = {},
onTransferNameChanged = {},
onSenderNameChanged = {},
onAccessPolicyChanged = { selectedPolicy = it },
onCreateShare = {},
onTransferSelected = {},
onCloseTransferDetails = {},
onCopyTicket = {},
onSubmit = {},
)
}
}
@@ -729,13 +691,6 @@ class FoundationComposeTest {
state = SendState(),
windowClass = WindowClass.Phone,
onOpenComposer = {},
onDismissComposer = {},
onSelectFile = {},
onClearFile = {},
onTransferNameChanged = {},
onSenderNameChanged = {},
onAccessPolicyChanged = {},
onCreateShare = {},
onTransferSelected = { selectedId = it },
onCloseTransferDetails = {},
onCopyTicket = {},
@@ -759,9 +714,7 @@ class FoundationComposeTest {
coreState = CoreState(isInitialized = true, transfers = listOf(outgoingTransfer())),
state = state.value,
windowClass = WindowClass.Desktop,
onOpenComposer = {}, onDismissComposer = {}, onSelectFile = {}, onClearFile = {},
onTransferNameChanged = {}, onSenderNameChanged = {}, onAccessPolicyChanged = {},
onCreateShare = {}, onTransferSelected = {}, onCloseTransferDetails = {}, onCopyTicket = {},
onOpenComposer = {}, onTransferSelected = {}, onCloseTransferDetails = {}, onCopyTicket = {},
onShare = { state.value = state.value.copy(detailPanel = com.vnidrop.app.feature.send.TransferDetailPanel.Share) },
)
}
@@ -791,9 +744,7 @@ class FoundationComposeTest {
detailPanel = com.vnidrop.app.feature.send.TransferDetailPanel.Share,
),
windowClass = WindowClass.Desktop,
onOpenComposer = {}, onDismissComposer = {}, onSelectFile = {}, onClearFile = {},
onTransferNameChanged = {}, onSenderNameChanged = {}, onAccessPolicyChanged = {},
onCreateShare = {}, onTransferSelected = {}, onCloseTransferDetails = {}, onCopyTicket = {},
onOpenComposer = {}, onTransferSelected = {}, onCloseTransferDetails = {}, onCopyTicket = {},
)
}
}
@@ -818,9 +769,7 @@ class FoundationComposeTest {
detailPanel = com.vnidrop.app.feature.send.TransferDetailPanel.Share,
),
windowClass = WindowClass.Desktop,
onOpenComposer = {}, onDismissComposer = {}, onSelectFile = {}, onClearFile = {},
onTransferNameChanged = {}, onSenderNameChanged = {}, onAccessPolicyChanged = {},
onCreateShare = {}, onTransferSelected = {}, onCloseTransferDetails = {}, onCopyTicket = {},
onOpenComposer = {}, onTransferSelected = {}, onCloseTransferDetails = {}, onCopyTicket = {},
)
}
}