mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-13 05:49:57 +02:00
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:
@@ -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 skill’s 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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -100,9 +100,6 @@
|
||||
<string name="error_starting_up">VniDrop démarre encore. Rouvrez l’invitation dans un instant.</string>
|
||||
<string name="error_storage_full">L’espace de stockage est insuffisant pour enregistrer ce transfert. Libérez de l’espace et réessayez.</string>
|
||||
<string name="error_transfer">Les données du transfert n’ont pas pu être traitées. Demandez à l’expé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 l’expé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 qu’Arrê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 d’appareil enregistré ?</string>
|
||||
<string name="saved_devices_block_confirm_title">Bloquer l’appareil ?</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 l’appareil : %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 l’enregistrement avant un autre transfert direct.</string>
|
||||
<string name="saved_devices_forget_confirm_title">Oublier l’appareil ?</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 d’actions pour %1$s</string>
|
||||
<string name="saved_devices_no_pending">Aucune demande d’association 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>
|
||||
|
||||
@@ -100,9 +100,6 @@
|
||||
<string name="error_starting_up">VniDrop è ancora in fase di avvio. Riapra l’invito 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -62,7 +62,6 @@ class AppGraph(
|
||||
)
|
||||
val pairingPromptCoordinator = PairingPromptCoordinator(
|
||||
repository = coreRepository,
|
||||
preferencesRepository = preferencesRepository,
|
||||
messages = messages,
|
||||
scope = applicationScope,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) + "…"
|
||||
|
||||
@@ -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) + "…"
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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) + "…"
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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) }
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user