diff --git a/localization/strings.json b/localization/strings.json index 91d15b1..8554207 100644 --- a/localization/strings.json +++ b/localization/strings.json @@ -1458,6 +1458,57 @@ "ru": "Не удалось обработать данные передачи. Попросите отправителя поделиться ими снова." } }, + "experimental_saved_devices_description": { + "context": "Settings > Experimental: toggle description for saved devices and targeted transfers.", + "targets": [ + "kmp" + ], + "translations": { + "en": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.", + "fr": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.", + "es": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.", + "it": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.", + "de": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.", + "pt": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.", + "pl": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.", + "nl": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.", + "ru": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change." + } + }, + "experimental_saved_devices_title": { + "context": "Settings > Experimental: toggle title for saved devices feature.", + "targets": [ + "kmp" + ], + "translations": { + "en": "Saved devices", + "fr": "Saved devices", + "es": "Saved devices", + "it": "Saved devices", + "de": "Saved devices", + "pt": "Saved devices", + "pl": "Saved devices", + "nl": "Saved devices", + "ru": "Saved devices" + } + }, + "experimental_settings_title": { + "context": "Settings: experimental section title (Android).", + "targets": [ + "kmp" + ], + "translations": { + "en": "Experimental", + "fr": "Experimental", + "es": "Experimental", + "it": "Experimental", + "de": "Experimental", + "pt": "Experimental", + "pl": "Experimental", + "nl": "Experimental", + "ru": "Experimental" + } + }, "field_receiver_name": { "context": "Text field label: the receiver's display name.", "translations": { @@ -4787,6 +4838,380 @@ "nl": "Weigeren", "ru": "Отклонить" } + }, + "saved_devices_empty": { + "context": "Experimental saved devices: empty list when none are saved yet.", + "translations": { + "en": "No saved devices yet. Finish a transfer, then remember a device.", + "fr": "No saved devices yet. Finish a transfer, then remember a device.", + "es": "No saved devices yet. Finish a transfer, then remember a device.", + "it": "No saved devices yet. Finish a transfer, then remember a device.", + "de": "No saved devices yet. Finish a transfer, then remember a device.", + "pt": "No saved devices yet. Finish a transfer, then remember a device.", + "pl": "No saved devices yet. Finish a transfer, then remember a device.", + "nl": "No saved devices yet. Finish a transfer, then remember a device.", + "ru": "No saved devices yet. Finish a transfer, then remember a device." + }, + "targets": [ + "kmp" + ] + }, + "saved_devices_eligibility_title": { + "context": "Experimental saved devices: section for peers eligible to pair after a transfer.", + "translations": { + "en": "Ready to remember", + "fr": "Ready to remember", + "es": "Ready to remember", + "it": "Ready to remember", + "de": "Ready to remember", + "pt": "Ready to remember", + "pl": "Ready to remember", + "nl": "Ready to remember", + "ru": "Ready to remember" + }, + "targets": [ + "kmp" + ] + }, + "saved_devices_pending_title": { + "context": "Experimental saved devices: section for in-progress pairing.", + "translations": { + "en": "Pending pairing", + "fr": "Pending pairing", + "es": "Pending pairing", + "it": "Pending pairing", + "de": "Pending pairing", + "pt": "Pending pairing", + "pl": "Pending pairing", + "nl": "Pending pairing", + "ru": "Pending pairing" + }, + "targets": [ + "kmp" + ] + }, + "saved_devices_list_title": { + "context": "Experimental saved devices: section listing saved devices.", + "translations": { + "en": "Saved devices", + "fr": "Saved devices", + "es": "Saved devices", + "it": "Saved devices", + "de": "Saved devices", + "pt": "Saved devices", + "pl": "Saved devices", + "nl": "Saved devices", + "ru": "Saved devices" + }, + "targets": [ + "kmp" + ] + }, + "saved_devices_unnamed": { + "context": "Fallback display when a saved device has no local label or remote name.", + "translations": { + "en": "Saved device", + "fr": "Saved device", + "es": "Saved device", + "it": "Saved device", + "de": "Saved device", + "pt": "Saved device", + "pl": "Saved device", + "nl": "Saved device", + "ru": "Saved device" + }, + "targets": [ + "kmp" + ] + }, + "saved_devices_remember_action": { + "context": "Action that starts mutual-consent pairing for an eligible peer.", + "translations": { + "en": "Remember", + "fr": "Remember", + "es": "Remember", + "it": "Remember", + "de": "Remember", + "pt": "Remember", + "pl": "Remember", + "nl": "Remember", + "ru": "Remember" + }, + "targets": [ + "kmp" + ] + }, + "saved_devices_decline_action": { + "context": "Action that declines pairing eligibility for a peer.", + "translations": { + "en": "Decline", + "fr": "Decline", + "es": "Decline", + "it": "Decline", + "de": "Decline", + "pt": "Decline", + "pl": "Decline", + "nl": "Decline", + "ru": "Decline" + }, + "targets": [ + "kmp" + ] + }, + "saved_devices_accept_pairing_action": { + "context": "Action that accepts an incoming pairing request.", + "translations": { + "en": "Accept", + "fr": "Accept", + "es": "Accept", + "it": "Accept", + "de": "Accept", + "pt": "Accept", + "pl": "Accept", + "nl": "Accept", + "ru": "Accept" + }, + "targets": [ + "kmp" + ] + }, + "saved_devices_send_action": { + "context": "Action that starts a targeted send to a saved device.", + "translations": { + "en": "Send files", + "fr": "Send files", + "es": "Send files", + "it": "Send files", + "de": "Send files", + "pt": "Send files", + "pl": "Send files", + "nl": "Send files", + "ru": "Send files" + }, + "targets": [ + "kmp" + ] + }, + "saved_devices_label_action": { + "context": "Action that edits the local label for a saved device.", + "translations": { + "en": "Label", + "fr": "Label", + "es": "Label", + "it": "Label", + "de": "Label", + "pt": "Label", + "pl": "Label", + "nl": "Label", + "ru": "Label" + }, + "targets": [ + "kmp" + ] + }, + "saved_devices_forget_action": { + "context": "Action that forgets a saved device relationship.", + "translations": { + "en": "Forget", + "fr": "Forget", + "es": "Forget", + "it": "Forget", + "de": "Forget", + "pt": "Forget", + "pl": "Forget", + "nl": "Forget", + "ru": "Forget" + }, + "targets": [ + "kmp" + ] + }, + "saved_devices_block_action": { + "context": "Action that blocks a device identity.", + "translations": { + "en": "Block", + "fr": "Block", + "es": "Block", + "it": "Block", + "de": "Block", + "pt": "Block", + "pl": "Block", + "nl": "Block", + "ru": "Block" + }, + "targets": [ + "kmp" + ] + }, + "saved_devices_label_title": { + "context": "Dialog title for editing a saved device local label.", + "translations": { + "en": "Device label", + "fr": "Device label", + "es": "Device label", + "it": "Device label", + "de": "Device label", + "pt": "Device label", + "pl": "Device label", + "nl": "Device label", + "ru": "Device label" + }, + "targets": [ + "kmp" + ] + }, + "saved_devices_label_placeholder": { + "context": "Placeholder for the local label text field.", + "translations": { + "en": "Label", + "fr": "Label", + "es": "Label", + "it": "Label", + "de": "Label", + "pt": "Label", + "pl": "Label", + "nl": "Label", + "ru": "Label" + }, + "targets": [ + "kmp" + ] + }, + "saved_devices_label_save": { + "context": "Button that saves the local device label.", + "translations": { + "en": "Save", + "fr": "Save", + "es": "Save", + "it": "Save", + "de": "Save", + "pt": "Save", + "pl": "Save", + "nl": "Save", + "ru": "Save" + }, + "targets": [ + "kmp" + ] + }, + "saved_devices_label_clear": { + "context": "Button that clears the local device label.", + "translations": { + "en": "Clear label", + "fr": "Clear label", + "es": "Clear label", + "it": "Clear label", + "de": "Clear label", + "pt": "Clear label", + "pl": "Clear label", + "nl": "Clear label", + "ru": "Clear label" + }, + "targets": [ + "kmp" + ] + }, + "saved_devices_pending_outgoing": { + "context": "Status text while waiting for the peer to accept pairing.", + "translations": { + "en": "Waiting for the other device", + "fr": "Waiting for the other device", + "es": "Waiting for the other device", + "it": "Waiting for the other device", + "de": "Waiting for the other device", + "pt": "Waiting for the other device", + "pl": "Waiting for the other device", + "nl": "Waiting for the other device", + "ru": "Waiting for the other device" + }, + "targets": [ + "kmp" + ] + }, + "saved_devices_pending_incoming": { + "context": "Status text when this device must accept a pairing request.", + "translations": { + "en": "Wants to remember this device", + "fr": "Wants to remember this device", + "es": "Wants to remember this device", + "it": "Wants to remember this device", + "de": "Wants to remember this device", + "pt": "Wants to remember this device", + "pl": "Wants to remember this device", + "nl": "Wants to remember this device", + "ru": "Wants to remember this device" + }, + "targets": [ + "kmp" + ] + }, + "saved_devices_send_started": { + "context": "Snackbar after creating a targeted transfer offer.", + "translations": { + "en": "Transfer offer sent", + "fr": "Transfer offer sent", + "es": "Transfer offer sent", + "it": "Transfer offer sent", + "de": "Transfer offer sent", + "pt": "Transfer offer sent", + "pl": "Transfer offer sent", + "nl": "Transfer offer sent", + "ru": "Transfer offer sent" + }, + "targets": [ + "kmp" + ] + }, + "saved_devices_forgotten": { + "context": "Snackbar after forgetting a saved device.", + "translations": { + "en": "Saved device forgotten", + "fr": "Saved device forgotten", + "es": "Saved device forgotten", + "it": "Saved device forgotten", + "de": "Saved device forgotten", + "pt": "Saved device forgotten", + "pl": "Saved device forgotten", + "nl": "Saved device forgotten", + "ru": "Saved device forgotten" + }, + "targets": [ + "kmp" + ] + }, + "saved_devices_blocked": { + "context": "Snackbar after blocking a device.", + "translations": { + "en": "Device blocked", + "fr": "Device blocked", + "es": "Device blocked", + "it": "Device blocked", + "de": "Device blocked", + "pt": "Device blocked", + "pl": "Device blocked", + "nl": "Device blocked", + "ru": "Device blocked" + }, + "targets": [ + "kmp" + ] + }, + "saved_devices_labeled": { + "context": "Snackbar after updating a saved device label.", + "translations": { + "en": "Label updated", + "fr": "Label updated", + "es": "Label updated", + "it": "Label updated", + "de": "Label updated", + "pt": "Label updated", + "pl": "Label updated", + "nl": "Label updated", + "ru": "Label updated" + }, + "targets": [ + "kmp" + ] } } } diff --git a/shared/docs/saved-devices-ui-contract.md b/shared/docs/saved-devices-ui-contract.md new file mode 100644 index 0000000..99e9b81 --- /dev/null +++ b/shared/docs/saved-devices-ui-contract.md @@ -0,0 +1,44 @@ +# Saved devices UI contract (KMP + Apple) + +Experimental product UI for saved devices and targeted transfers. Rust UniFFI is the source of behaviour; this document keeps Apple and KMP interaction semantics aligned. + +Status: experimental (default off in KMP Settings). + +## Vocabulary + +Use **saved device**, **device relationship**, **targeted transfer**, **invitation transfer**. Do not say contact, person, or account in UI copy. + +## Experimental gate + +- KMP Android: Settings → Experimental → Saved devices, preference default **off**, persisted. +- KMP desktop: experimental UI **hidden**. +- Apple: gate shape is platform-owned; semantics below still apply when the feature is enabled. + +## Events are wake-ups + +`pairing` and `targeted_transfer` core events (and KMP `CoreSignal.PairingChanged` / `TargetedTransferChanged`) mean: refresh durable state via list/get APIs. Do not treat event payloads as authoritative storage. Deduplicate by event id/revision when needed. + +Relevant kinds: + +- **pairing:** `eligibility-available`, `eligibility-removed`, `relationship-changed`, `relationship-grant-rotated`, `saved-device-forgotten`, `device-blocked` +- **targeted_transfer:** `offer-received`, `offer-accepted`, `offer-declined` + +## Pairing + +After a completed invitation transfer, eligibility may exist. The user may accept or decline. Mutual consent yields a saved device. Pending eligibility/relationship state must remain reachable if an in-flow prompt is dismissed. + +## Targeted approve / pull + +1. List pending targeted offers (or react to `offer-received`). +2. `respond_to_targeted_offer(transfer_id, accepted)` → typed outcome only: + - `Approved { transfer_id }` + - `Declined` + - `AlreadySettled { transfer_id }` +3. Never accept or display authorization/grant strings across the public binding. +4. Pull / resume with **transfer id + destination** (path or output sink). Android KMP reuses the invitation MediaStore Downloads sink for the experimental MVP receive. + +## Out of this contract’s MVP chrome + +Resume / cancel / delete / grant-rotate UI, background notifications, and mid-transfer progress polish may follow without changing the approve/pull rules above. + +Block is available from the Saved devices area. Unblock is not required for the KMP MVP chrome when the product surface does not already expose blocked-device management. diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt index 02d9d2c..45c561b 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt @@ -142,7 +142,23 @@ private class AndroidFileSystemService( transferName: String, senderName: String, accessPolicy: ShareAccessPolicy, - ): Result = runCatching { + ): Result = withAndroidShareSources(files) { sources -> + repository.shareSources(sources, transferName, senderName, accessPolicy).getOrThrow() + } + + override suspend fun createTargetedTransferFromPickedFiles( + repository: CoreGateway, + receiverEndpointId: String, + files: List, + transferName: String?, + ): Result = withAndroidShareSources(files) { sources -> + repository.createTargetedTransfer(receiverEndpointId, sources, transferName).getOrThrow() + } + + private suspend fun withAndroidShareSources( + files: List, + block: suspend (List) -> T, + ): Result = 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. @@ -163,7 +179,7 @@ private class AndroidFileSystemService( isDirectory = false, ) } - repository.shareSources(sources, transferName, senderName, accessPolicy).getOrThrow() + block(sources) } finally { descriptors.forEach { it.close() } } diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index 87b7277..6b91218 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -100,6 +100,9 @@ VniDrop startet noch. Öffnen Sie die Einladung gleich erneut. Zum Speichern dieser Übertragung ist nicht genügend Speicherplatz vorhanden. Geben Sie Speicherplatz frei und versuchen Sie es erneut. Die Übertragungsdaten konnten nicht verarbeitet werden. Bitten Sie den Absender, sie erneut zu teilen. + Remember devices after a transfer and send to them again without a new invitation. Experimental and may change. + Saved devices + Experimental Empfängername Absendername Übertragungsname @@ -317,6 +320,28 @@ %1$s möchte Ihnen „%2$s“ senden. Empfangen Ablehnen + No saved devices yet. Finish a transfer, then remember a device. + Ready to remember + Pending pairing + Saved devices + Saved device + Remember + Decline + Accept + Send files + Label + Forget + Block + Device label + Label + Save + Clear label + Waiting for the other device + Wants to remember this device + Transfer offer sent + Saved device forgotten + Device blocked + Label updated %1$d Datei %1$d Dateien diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index 3aa9e22..e7680d2 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -100,6 +100,9 @@ VniDrop todavía se está iniciando. Vuelva a abrir la invitación en un momento. No hay suficiente espacio de almacenamiento para guardar esta transferencia. Libere espacio e inténtelo de nuevo. No se pudieron procesar los datos de la transferencia. Pida al remitente que vuelva a compartirlos. + Remember devices after a transfer and send to them again without a new invitation. Experimental and may change. + Saved devices + Experimental Nombre del destinatario Nombre del remitente Nombre de la transferencia @@ -317,6 +320,28 @@ %1$s quiere enviarte «%2$s». Recibir Rechazar + No saved devices yet. Finish a transfer, then remember a device. + Ready to remember + Pending pairing + Saved devices + Saved device + Remember + Decline + Accept + Send files + Label + Forget + Block + Device label + Label + Save + Clear label + Waiting for the other device + Wants to remember this device + Transfer offer sent + Saved device forgotten + Device blocked + Label updated %1$d archivo %1$d archivos diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index a1cb5aa..24db4da 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -100,6 +100,9 @@ VniDrop démarre encore. Rouvrez l’invitation dans un instant. L’espace de stockage est insuffisant pour enregistrer ce transfert. Libérez de l’espace et réessayez. Les données du transfert n’ont pas pu être traitées. Demandez à l’expéditeur de les partager à nouveau. + Remember devices after a transfer and send to them again without a new invitation. Experimental and may change. + Saved devices + Experimental Nom du destinataire Nom de l’expéditeur Nom du transfert @@ -317,6 +320,28 @@ %1$s souhaite vous envoyer « %2$s ». Recevoir Refuser + No saved devices yet. Finish a transfer, then remember a device. + Ready to remember + Pending pairing + Saved devices + Saved device + Remember + Decline + Accept + Send files + Label + Forget + Block + Device label + Label + Save + Clear label + Waiting for the other device + Wants to remember this device + Transfer offer sent + Saved device forgotten + Device blocked + Label updated %1$d fichier %1$d fichiers diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index 2875ff1..71a8bdc 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -100,6 +100,9 @@ VniDrop è ancora in fase di avvio. Riapra l’invito tra un momento. Lo spazio di archiviazione non è sufficiente per salvare il trasferimento. Liberi spazio e riprovi. Non è stato possibile elaborare i dati del trasferimento. Chieda al mittente di condividerli di nuovo. + Remember devices after a transfer and send to them again without a new invitation. Experimental and may change. + Saved devices + Experimental Nome del destinatario Nome del mittente Nome del trasferimento @@ -317,6 +320,28 @@ %1$s vuole inviarti «%2$s». Ricevi Rifiuta + No saved devices yet. Finish a transfer, then remember a device. + Ready to remember + Pending pairing + Saved devices + Saved device + Remember + Decline + Accept + Send files + Label + Forget + Block + Device label + Label + Save + Clear label + Waiting for the other device + Wants to remember this device + Transfer offer sent + Saved device forgotten + Device blocked + Label updated %1$d file %1$d file diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index 99bc999..e6c6c47 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -100,6 +100,9 @@ VniDrop is nog aan het opstarten. Open de uitnodiging zo meteen opnieuw. Er is onvoldoende opslagruimte om deze overdracht op te slaan. Maak ruimte vrij en probeer het opnieuw. De overdrachtsgegevens konden niet worden verwerkt. Vraag de afzender ze opnieuw te delen. + Remember devices after a transfer and send to them again without a new invitation. Experimental and may change. + Saved devices + Experimental Naam van ontvanger Naam van afzender Naam van overdracht @@ -317,6 +320,28 @@ %1$s wil je “%2$s” sturen. Ontvangen Weigeren + No saved devices yet. Finish a transfer, then remember a device. + Ready to remember + Pending pairing + Saved devices + Saved device + Remember + Decline + Accept + Send files + Label + Forget + Block + Device label + Label + Save + Clear label + Waiting for the other device + Wants to remember this device + Transfer offer sent + Saved device forgotten + Device blocked + Label updated %1$d bestand %1$d bestanden diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index b598cb1..ab3773f 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -100,6 +100,9 @@ VniDrop jeszcze się uruchamia. Otwórz zaproszenie ponownie za chwilę. Brakuje miejsca na zapisanie tego transferu. Zwolnij miejsce i spróbuj ponownie. Nie udało się przetworzyć danych transferu. Poproś nadawcę o ponowne udostępnienie. + Remember devices after a transfer and send to them again without a new invitation. Experimental and may change. + Saved devices + Experimental Nazwa odbiorcy Nazwa nadawcy Nazwa transferu @@ -317,6 +320,28 @@ %1$s chce wysłać Ci „%2$s”. Odbierz Odrzuć + No saved devices yet. Finish a transfer, then remember a device. + Ready to remember + Pending pairing + Saved devices + Saved device + Remember + Decline + Accept + Send files + Label + Forget + Block + Device label + Label + Save + Clear label + Waiting for the other device + Wants to remember this device + Transfer offer sent + Saved device forgotten + Device blocked + Label updated %1$d plik %1$d pliki diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index 879e58b..3212e1a 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -100,6 +100,9 @@ O VniDrop ainda está a iniciar. Abra o convite novamente dentro de momentos. Não existe espaço de armazenamento suficiente para guardar esta transferência. Liberte espaço e tente novamente. Não foi possível processar os dados da transferência. Peça ao remetente para os partilhar novamente. + Remember devices after a transfer and send to them again without a new invitation. Experimental and may change. + Saved devices + Experimental Nome do destinatário Nome do remetente Nome da transferência @@ -317,6 +320,28 @@ %1$s quer enviar-lhe “%2$s”. Receber Recusar + No saved devices yet. Finish a transfer, then remember a device. + Ready to remember + Pending pairing + Saved devices + Saved device + Remember + Decline + Accept + Send files + Label + Forget + Block + Device label + Label + Save + Clear label + Waiting for the other device + Wants to remember this device + Transfer offer sent + Saved device forgotten + Device blocked + Label updated %1$d ficheiro %1$d ficheiros diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index 0b3ce11..d2008a7 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -100,6 +100,9 @@ VniDrop ещё запускается. Откройте приглашение снова через мгновение. Недостаточно места для сохранения этой передачи. Освободите место и повторите попытку. Не удалось обработать данные передачи. Попросите отправителя поделиться ими снова. + Remember devices after a transfer and send to them again without a new invitation. Experimental and may change. + Saved devices + Experimental Имя получателя Имя отправителя Название передачи @@ -317,6 +320,28 @@ %1$s хочет отправить вам «%2$s». Получить Отклонить + No saved devices yet. Finish a transfer, then remember a device. + Ready to remember + Pending pairing + Saved devices + Saved device + Remember + Decline + Accept + Send files + Label + Forget + Block + Device label + Label + Save + Clear label + Waiting for the other device + Wants to remember this device + Transfer offer sent + Saved device forgotten + Device blocked + Label updated %1$d файл %1$d файла diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 7fcdf25..eb8eb3b 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -100,6 +100,9 @@ VniDrop is still starting. Open the invitation again in a moment. There is not enough storage space to save this transfer. Free up space and try again. The transfer data could not be processed. Ask the sender to share it again. + Remember devices after a transfer and send to them again without a new invitation. Experimental and may change. + Saved devices + Experimental Receiver name Sender name Transfer name @@ -317,6 +320,28 @@ %1$s wants to send you “%2$s”. Receive Decline + No saved devices yet. Finish a transfer, then remember a device. + Ready to remember + Pending pairing + Saved devices + Saved device + Remember + Decline + Accept + Send files + Label + Forget + Block + Device label + Label + Save + Clear label + Waiting for the other device + Wants to remember this device + Transfer offer sent + Saved device forgotten + Device blocked + Label updated %1$d files %1$d file diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index de8e75a..aae57bd 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -37,6 +37,9 @@ import com.vnidrop.app.feature.receive.ReceiveRoute import com.vnidrop.app.feature.receive.ReceiveFloatingAction import com.vnidrop.app.feature.receive.ReceiveViewModel import com.vnidrop.app.feature.receive.ReceiveMethod +import com.vnidrop.app.feature.saveddevices.PairingPromptHost +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 @@ -106,12 +109,22 @@ fun App( graph.diagnostics.bugReports, ) } + val savedDevicesViewModel = viewModel { + SavedDevicesViewModel( + graph.coreRepository, + dependencies.fileSystemService, + graph.preferencesRepository, + graph.messages, + ) + } val appState by appViewModel.state.collectAsStateWithLifecycle() val sendState by sendViewModel.state.collectAsStateWithLifecycle() val sendCoreState by sendViewModel.coreState.collectAsStateWithLifecycle() val receiveState by receiveViewModel.state.collectAsStateWithLifecycle() val receiveCoreState by receiveViewModel.coreState.collectAsStateWithLifecycle() val approvalState by graph.approvalCoordinator.state.collectAsStateWithLifecycle() + val pairingPromptState by graph.pairingPromptCoordinator.state.collectAsStateWithLifecycle() + val targetedOfferState by graph.targetedOfferCoordinator.state.collectAsStateWithLifecycle() val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(dependencies.externalInvitations, appViewModel, receiveViewModel) { dependencies.externalInvitations.invitations.collect { invitation -> @@ -217,7 +230,9 @@ fun App( when (appState.destination) { AppDestination.Send -> SendRoute(sendViewModel, windowClass) AppDestination.Receive -> ReceiveRoute(receiveViewModel, windowClass) - AppDestination.Settings -> ScreenScrollContainer { SettingsRoute(settingsViewModel, windowClass) } + AppDestination.Settings -> ScreenScrollContainer { + SettingsRoute(settingsViewModel, savedDevicesViewModel, windowClass) + } } } ApprovalModalHost( @@ -225,6 +240,17 @@ fun App( onAccept = graph.approvalCoordinator::accept, onRefuse = graph.approvalCoordinator::refuse, ) + PairingPromptHost( + state = pairingPromptState, + onAccept = graph.pairingPromptCoordinator::accept, + onDecline = graph.pairingPromptCoordinator::decline, + onDismiss = graph.pairingPromptCoordinator::dismiss, + ) + TargetedOfferModalHost( + state = targetedOfferState, + onAccept = graph.targetedOfferCoordinator::accept, + onDecline = graph.targetedOfferCoordinator::decline, + ) } windowChrome?.invoke() val startingLabel = stringResource(Res.string.app_starting) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt index 304d917..06c4887 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt @@ -5,6 +5,8 @@ import com.vnidrop.app.core.CoreRepository import com.vnidrop.app.diagnostics.DiagnosticsCoordinator import com.vnidrop.app.diagnostics.createDiagnosticsTransport import com.vnidrop.app.feature.approvals.ApprovalCoordinator +import com.vnidrop.app.feature.saveddevices.PairingPromptCoordinator +import com.vnidrop.app.feature.saveddevices.TargetedOfferCoordinator import com.vnidrop.app.feature.send.AppFilePreviewRepository import com.vnidrop.app.feature.send.createPlatformPreviewStore import com.vnidrop.app.logging.AppLogger @@ -58,6 +60,19 @@ class AppGraph( messages = messages, scope = applicationScope, ) + val pairingPromptCoordinator = PairingPromptCoordinator( + repository = coreRepository, + preferencesRepository = preferencesRepository, + messages = messages, + scope = applicationScope, + ) + val targetedOfferCoordinator = TargetedOfferCoordinator( + repository = coreRepository, + fileSystemService = dependencies.fileSystemService, + preferencesRepository = preferencesRepository, + messages = messages, + scope = applicationScope, + ) val transferNotificationCoordinator = TransferNotificationCoordinator( repository = coreRepository, preferencesRepository = preferencesRepository, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreEventSignals.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreEventSignals.kt new file mode 100644 index 0000000..96cd69e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreEventSignals.kt @@ -0,0 +1,20 @@ +package com.vnidrop.app.core + +/** + * Maps a durable core event phase to wake-up signals. + * Pairing / targeted phases wake even when `transferId` is absent (endpoint-scoped events). + */ +internal fun signalsForCoreEvent(phase: String, transferId: ULong?): List { + val signals = mutableListOf() + when (phase) { + "pairing" -> signals += CoreSignal.PairingChanged + "targeted_transfer" -> signals += CoreSignal.TargetedTransferChanged + } + if (transferId != null) { + when (phase) { + "approval", "access" -> signals += CoreSignal.ApprovalChanged(transferId) + "delivery" -> signals += CoreSignal.ReceiverHistoryChanged(transferId) + } + } + return signals +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt index 7e4f87d..e3a3e5b 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt @@ -153,6 +153,10 @@ sealed interface CoreSignal { data class ReceiverHistoryChanged(val transferId: ULong) : CoreSignal /** Transfer status/history changed enough to re-read the durable snapshot. */ data class TransfersChanged(val transferId: ULong) : CoreSignal + /** Pairing / saved-device state changed; refresh eligibility, relationships, and saved list. */ + data object PairingChanged : CoreSignal + /** Targeted-transfer offer or lifecycle changed; refresh pending offers and transfers. */ + data object TargetedTransferChanged : CoreSignal } interface CoreGateway { @@ -192,4 +196,38 @@ interface CoreGateway { suspend fun receiverRequests(transferId: ULong): Result> suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null): Result suspend fun refresh(): Result + + // Experimental saved devices / targeted transfers + suspend fun listPairingEligibilities(): Result> + suspend fun declinePairingEligibility(peerEndpointId: String): Result + suspend fun requestSavedDevicePairing(peerEndpointId: String): Result + suspend fun respondToDevicePairing(peerEndpointId: String, accepted: Boolean): Result + suspend fun listDeviceRelationships(): Result> + suspend fun listSavedDevices(): Result> + suspend fun setSavedDeviceLabel(peerEndpointId: String, label: String?): Result + suspend fun forgetSavedDevice(peerEndpointId: String): Result + suspend fun blockDevice(peerEndpointId: String): Result + suspend fun unblockDevice(peerEndpointId: String): Result + suspend fun listBlockedDevices(): Result> + suspend fun listPendingTargetedOffers(): Result> + suspend fun respondToTargetedOffer(transferId: String, accepted: Boolean): Result + suspend fun createTargetedTransfer( + receiverEndpointId: String, + sources: List, + transferName: String?, + ): Result + suspend fun getTargetedTransfer(id: String): Result + suspend fun listTargetedTransfers(): Result> + suspend fun receiveTargetedTransfer(transferId: String, outputDir: String): Result + suspend fun receiveTargetedTransferWithOutputSink( + transferId: String, + outputSink: ReceiveOutputSink, + ): Result + suspend fun receiveTargetedTransferWithOutputSinkV2( + transferId: String, + outputSink: ReceiveOutputSinkV2, + ): Result + suspend fun resumeTargetedTransfer(id: String, outputDir: String): Result + suspend fun cancelTargetedTransfer(id: String): Result + suspend fun deleteTargetedTransfer(id: String): Result } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index 1b305d6..d76172b 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -18,14 +18,22 @@ import uniffi.vnidrop.CoreEvent import uniffi.vnidrop.CoreEventSink import uniffi.vnidrop.CoreNetworkConfig import uniffi.vnidrop.CoreRelayMode +import uniffi.vnidrop.DeviceRelationship +import uniffi.vnidrop.DeviceRelationshipState +import uniffi.vnidrop.PairingEligibilitySummary +import uniffi.vnidrop.PendingTargetedOffer import uniffi.vnidrop.ReceiveOutputSink import uniffi.vnidrop.ReceiveOutputSinkV2 import uniffi.vnidrop.ReceiverRequest +import uniffi.vnidrop.SavedDevice import uniffi.vnidrop.ShareMetadataInput import uniffi.vnidrop.ShareResult import uniffi.vnidrop.ShareSource import uniffi.vnidrop.SourceKind import uniffi.vnidrop.StoredTransfer +import uniffi.vnidrop.TargetedOfferResponse +import uniffi.vnidrop.TargetedTransfer +import uniffi.vnidrop.TargetedTransferState import uniffi.vnidrop.TicketInspection import uniffi.vnidrop.TransferMetadata import uniffi.vnidrop.TransferAccessMode @@ -56,15 +64,12 @@ class CoreRepository internal constructor( override fun onEvent(event: CoreEvent) { val model = event.toModel() _state.update { current -> current.copy(events = (listOf(model) + current.events).take(MaxEvents)) } + for (signal in signalsForCoreEvent(model.phase, model.transferId)) { + _signals.tryEmit(signal) + } val transferId = model.transferId - if (transferId != null) { - when (model.phase) { - "approval", "access" -> _signals.tryEmit(CoreSignal.ApprovalChanged(transferId)) - "delivery" -> _signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId)) - } - if (model.shouldRefreshTransfers()) { - _signals.tryEmit(CoreSignal.TransfersChanged(transferId)) - } + if (transferId != null && model.shouldRefreshTransfers()) { + _signals.tryEmit(CoreSignal.TransfersChanged(transferId)) } } } @@ -255,6 +260,112 @@ class CoreRepository internal constructor( override suspend fun refresh(): Result = runCore(::refreshSnapshot) + override suspend fun listPairingEligibilities(): Result> = runCore { activeCore -> + activeCore.listPairingEligibilities().map { it.toModel() } + } + + override suspend fun declinePairingEligibility(peerEndpointId: String): Result = runCore { activeCore -> + activeCore.declinePairingEligibility(peerEndpointId) + } + + override suspend fun requestSavedDevicePairing(peerEndpointId: String): Result = runCore { activeCore -> + activeCore.requestSavedDevicePairing(peerEndpointId) + } + + override suspend fun respondToDevicePairing(peerEndpointId: String, accepted: Boolean): Result = + runCore { activeCore -> + activeCore.respondToDevicePairing(peerEndpointId, accepted) + } + + override suspend fun listDeviceRelationships(): Result> = runCore { activeCore -> + activeCore.listDeviceRelationships().map { it.toModel() } + } + + override suspend fun listSavedDevices(): Result> = runCore { activeCore -> + activeCore.listSavedDevices().map { it.toModel() } + } + + override suspend fun setSavedDeviceLabel(peerEndpointId: String, label: String?): Result = runCore { activeCore -> + activeCore.setSavedDeviceLabel(peerEndpointId, label) + } + + override suspend fun forgetSavedDevice(peerEndpointId: String): Result = runCore { activeCore -> + activeCore.forgetSavedDevice(peerEndpointId) + } + + override suspend fun blockDevice(peerEndpointId: String): Result = runCore { activeCore -> + activeCore.blockDevice(peerEndpointId) + } + + override suspend fun unblockDevice(peerEndpointId: String): Result = runCore { activeCore -> + activeCore.unblockDevice(peerEndpointId) + } + + override suspend fun listBlockedDevices(): Result> = runCore { activeCore -> + activeCore.listBlockedDevices() + } + + override suspend fun listPendingTargetedOffers(): Result> = runCore { activeCore -> + activeCore.listPendingTargetedOffers().map { it.toModel() } + } + + override suspend fun respondToTargetedOffer( + transferId: String, + accepted: Boolean, + ): Result = runCore { activeCore -> + activeCore.respondToTargetedOffer(transferId, accepted).toModel() + } + + override suspend fun createTargetedTransfer( + receiverEndpointId: String, + sources: List, + transferName: String?, + ): Result = runCore { activeCore -> + require(sources.isNotEmpty()) { "Select at least one file to share" } + withPlatformPathAccess(sources) { + activeCore.createTargetedTransfer(receiverEndpointId, sources, transferName).toModel() + } + } + + override suspend fun getTargetedTransfer(id: String): Result = runCore { activeCore -> + activeCore.getTargetedTransfer(id)?.toModel() + } + + override suspend fun listTargetedTransfers(): Result> = runCore { activeCore -> + activeCore.listTargetedTransfers().map { it.toModel() } + } + + override suspend fun receiveTargetedTransfer(transferId: String, outputDir: String): Result = + runCore { activeCore -> + activeCore.receiveTargetedTransfer(transferId, outputDir) + } + + override suspend fun receiveTargetedTransferWithOutputSink( + transferId: String, + outputSink: ReceiveOutputSink, + ): Result = runCore { activeCore -> + activeCore.receiveTargetedTransferWithOutputSink(transferId, outputSink) + } + + override suspend fun receiveTargetedTransferWithOutputSinkV2( + transferId: String, + outputSink: ReceiveOutputSinkV2, + ): Result = runCore { activeCore -> + activeCore.receiveTargetedTransferWithOutputSinkV2(transferId, outputSink) + } + + override suspend fun resumeTargetedTransfer(id: String, outputDir: String): Result = runCore { activeCore -> + activeCore.resumeTargetedTransfer(id, outputDir) + } + + override suspend fun cancelTargetedTransfer(id: String): Result = runCore { activeCore -> + activeCore.cancelTargetedTransfer(id) + } + + override suspend fun deleteTargetedTransfer(id: String): Result = runCore { activeCore -> + activeCore.deleteTargetedTransfer(id) + } + override suspend fun shareSources( sources: List, transferName: String, @@ -475,3 +586,83 @@ private fun ReceiverRequest.toModel(): ReceiverRequestModel = ReceiverRequestMod respondedAt = respondedAt, completedAt = completedAt, ) + +private fun PairingEligibilitySummary.toModel(): PairingEligibilityModel = PairingEligibilityModel( + peerEndpointId = peerEndpointId, + sessionId = sessionId, + protocolVersion = protocolVersion, + createdAt = createdAt, + expiresAt = expiresAt, +) + +private fun DeviceRelationship.toModel(): DeviceRelationshipModel = DeviceRelationshipModel( + remoteEndpointId = remoteEndpointId, + state = state.toModel(), + generation = generation, + minimumProtocolVersion = minimumProtocolVersion, + createdAt = createdAt, + updatedAt = updatedAt, +) + +private fun DeviceRelationshipState.toModel(): DeviceRelationshipStateModel = when (this) { + DeviceRelationshipState.PENDING_OUTGOING -> DeviceRelationshipStateModel.PendingOutgoing + DeviceRelationshipState.PENDING_INCOMING -> DeviceRelationshipStateModel.PendingIncoming + DeviceRelationshipState.SAVED -> DeviceRelationshipStateModel.Saved + DeviceRelationshipState.REVOKED -> DeviceRelationshipStateModel.Revoked + DeviceRelationshipState.BLOCKED -> DeviceRelationshipStateModel.Blocked +} + +private fun SavedDevice.toModel(): SavedDeviceModel = SavedDeviceModel( + endpointId = endpointId, + localLabel = localLabel, + remoteDisplayName = remoteDisplayName, + createdAt = createdAt, + lastAuthenticatedAt = lastAuthenticatedAt, +) + +private fun PendingTargetedOffer.toModel(): PendingTargetedOfferModel = PendingTargetedOfferModel( + transferId = transferId, + senderEndpointId = senderEndpointId, + receiverEndpointId = receiverEndpointId, + manifestId = manifestId, + contentHash = contentHash, + transferName = transferName, + fileCount = fileCount, + totalSize = totalSize, + protocolVersion = protocolVersion, + receivedAt = receivedAt, +) + +private fun TargetedTransfer.toModel(): TargetedTransferModel = TargetedTransferModel( + id = id, + senderEndpointId = senderEndpointId, + receiverEndpointId = receiverEndpointId, + manifestId = manifestId, + fileCount = fileCount, + totalSize = totalSize, + verifiedBytes = verifiedBytes, + state = state.toModel(), + createdAt = createdAt, + updatedAt = updatedAt, +) + +private fun TargetedTransferState.toModel(): TargetedTransferStateModel = when (this) { + TargetedTransferState.PREPARING -> TargetedTransferStateModel.Preparing + TargetedTransferState.OFFERING -> TargetedTransferStateModel.Offering + TargetedTransferState.AWAITING_APPROVAL -> TargetedTransferStateModel.AwaitingApproval + TargetedTransferState.APPROVED -> TargetedTransferStateModel.Approved + TargetedTransferState.CONNECTING -> TargetedTransferStateModel.Connecting + TargetedTransferState.TRANSFERRING -> TargetedTransferStateModel.Transferring + TargetedTransferState.INTERRUPTED -> TargetedTransferStateModel.Interrupted + TargetedTransferState.COMPLETED -> TargetedTransferStateModel.Completed + TargetedTransferState.DECLINED -> TargetedTransferStateModel.Declined + TargetedTransferState.CANCELLED -> TargetedTransferStateModel.Cancelled + TargetedTransferState.FAILED -> TargetedTransferStateModel.Failed + TargetedTransferState.DELETED -> TargetedTransferStateModel.Deleted +} + +private fun TargetedOfferResponse.toModel(): TargetedOfferResponseModel = when (this) { + is TargetedOfferResponse.Approved -> TargetedOfferResponseModel.Approved(transferId) + TargetedOfferResponse.Declined -> TargetedOfferResponseModel.Declined + is TargetedOfferResponse.AlreadySettled -> TargetedOfferResponseModel.AlreadySettled(transferId) +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt index a36a4c1..1f8edb8 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt @@ -64,6 +64,14 @@ interface FileSystemService { senderName: String, accessPolicy: ShareAccessPolicy, ): Result + + /** Builds platform share sources and creates a targeted transfer to a saved device. */ + suspend fun createTargetedTransferFromPickedFiles( + repository: CoreGateway, + receiverEndpointId: String, + files: List, + transferName: String?, + ): Result } @Composable diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/SavedDeviceModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/SavedDeviceModels.kt new file mode 100644 index 0000000..1db6e35 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/SavedDeviceModels.kt @@ -0,0 +1,87 @@ +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. + */ + +data class SavedDeviceModel( + val endpointId: String, + val localLabel: String?, + val remoteDisplayName: String?, + val createdAt: Long, + val lastAuthenticatedAt: Long?, +) + +enum class DeviceRelationshipStateModel { + PendingOutgoing, + PendingIncoming, + Saved, + Revoked, + Blocked, +} + +data class DeviceRelationshipModel( + val remoteEndpointId: String, + val state: DeviceRelationshipStateModel, + val generation: ULong, + val minimumProtocolVersion: UShort, + val createdAt: Long, + val updatedAt: Long, +) + +data class PairingEligibilityModel( + val peerEndpointId: String, + val sessionId: String, + val protocolVersion: UShort, + val createdAt: Long, + val expiresAt: Long, +) + +data class PendingTargetedOfferModel( + val transferId: String, + val senderEndpointId: String, + val receiverEndpointId: String, + val manifestId: String, + val contentHash: String, + val transferName: String, + val fileCount: ULong, + val totalSize: ULong, + val protocolVersion: UShort, + val receivedAt: Long, +) + +enum class TargetedTransferStateModel { + Preparing, + Offering, + AwaitingApproval, + Approved, + Connecting, + Transferring, + Interrupted, + Completed, + Declined, + Cancelled, + Failed, + Deleted, +} + +data class TargetedTransferModel( + val id: String, + val senderEndpointId: String, + val receiverEndpointId: String, + val manifestId: String, + val fileCount: ULong, + val totalSize: ULong, + val verifiedBytes: ULong, + val state: TargetedTransferStateModel, + val createdAt: Long, + val updatedAt: Long, +) + +sealed interface TargetedOfferResponseModel { + data class Approved(val transferId: String) : TargetedOfferResponseModel + data object Declined : TargetedOfferResponseModel + data class AlreadySettled(val transferId: String) : TargetedOfferResponseModel +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt index 22b9aa5..6b678ac 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt @@ -64,7 +64,9 @@ class ApprovalCoordinator( when (signal) { is CoreSignal.ApprovalChanged -> refresh(signal.transferId) is CoreSignal.ReceiverHistoryChanged, - is CoreSignal.TransfersChanged -> Unit + is CoreSignal.TransfersChanged, + CoreSignal.PairingChanged, + CoreSignal.TargetedTransferChanged -> Unit } } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt index 1aab08d..1b65fe3 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt @@ -93,7 +93,9 @@ class ReceiveViewModel( } } is CoreSignal.ApprovalChanged, - is CoreSignal.ReceiverHistoryChanged -> Unit + is CoreSignal.ReceiverHistoryChanged, + CoreSignal.PairingChanged, + CoreSignal.TargetedTransferChanged -> Unit } } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptCoordinator.kt new file mode 100644 index 0000000..feee50f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptCoordinator.kt @@ -0,0 +1,138 @@ +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.update +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 + + /** Peer requested pairing; user may accept or decline mutual consent. */ + data class IncomingRequest(val peerEndpointId: String) : PairingPrompt +} + +data class PairingPromptState( + val enabled: Boolean = false, + val prompt: PairingPrompt? = null, + val busy: Boolean = false, +) + +/** + * Foreground in-flow pairing prompts. Dismiss keeps durable eligibility / + * pending relationship visible in the Saved devices area; Decline consumes it. + */ +class PairingPromptCoordinator( + private val repository: CoreGateway, + private val preferencesRepository: PreferencesRepository, + private val messages: UiMessageController, + private val scope: CoroutineScope, +) { + private val _state = MutableStateFlow(PairingPromptState()) + val state: StateFlow = _state.asStateFlow() + + /** Peers whose in-flow eligibility prompt was dismissed this session. */ + private val dismissedEligibility = mutableSetOf() + + init { + scope.launch { + preferencesRepository.preferences.collectLatest { preferences -> + val enabled = preferences.experimentalSavedDevicesEnabled + _state.update { it.copy(enabled = enabled) } + if (enabled) { + refresh() + } else { + _state.update { it.copy(prompt = null, busy = false) } + } + } + } + scope.launch { + repository.signals.collect { signal -> + when (signal) { + CoreSignal.PairingChanged -> if (_state.value.enabled) refresh() + is CoreSignal.ApprovalChanged, + is CoreSignal.ReceiverHistoryChanged, + is CoreSignal.TransfersChanged, + CoreSignal.TargetedTransferChanged -> Unit + } + } + } + } + + fun accept() { + val prompt = _state.value.prompt ?: return + if (_state.value.busy) return + _state.update { it.copy(busy = true) } + scope.launch { + val result = when (prompt) { + is PairingPrompt.Eligibility -> repository.requestSavedDevicePairing(prompt.peerEndpointId) + is PairingPrompt.IncomingRequest -> + repository.respondToDevicePairing(prompt.peerEndpointId, accepted = true) + } + _state.update { it.copy(busy = false) } + result.fold( + onSuccess = { refresh() }, + onFailure = messages::error, + ) + } + } + + fun decline() { + val prompt = _state.value.prompt ?: return + if (_state.value.busy) return + _state.update { it.copy(busy = true) } + scope.launch { + val result = when (prompt) { + is PairingPrompt.Eligibility -> repository.declinePairingEligibility(prompt.peerEndpointId) + is PairingPrompt.IncomingRequest -> + repository.respondToDevicePairing(prompt.peerEndpointId, accepted = false) + } + _state.update { it.copy(busy = false) } + result.fold( + onSuccess = { refresh() }, + onFailure = messages::error, + ) + } + } + + /** Close the dialog without consuming durable eligibility / pending state. */ + fun dismiss() { + val prompt = _state.value.prompt ?: return + if (prompt is PairingPrompt.Eligibility) { + dismissedEligibility += prompt.peerEndpointId + } + _state.update { it.copy(prompt = null) } + } + + private suspend fun refresh() { + if (!_state.value.enabled || _state.value.busy) return + val relationships = repository.listDeviceRelationships().getOrElse { + messages.error(it) + return + } + val incoming = relationships.firstOrNull { it.state == DeviceRelationshipStateModel.PendingIncoming } + if (incoming != null) { + _state.update { + it.copy(prompt = PairingPrompt.IncomingRequest(incoming.remoteEndpointId)) + } + return + } + val eligibilities = repository.listPairingEligibilities().getOrElse { + messages.error(it) + return + } + val eligibility = eligibilities.firstOrNull { it.peerEndpointId !in dismissedEligibility } + _state.update { + it.copy(prompt = eligibility?.let { row -> PairingPrompt.Eligibility(row.peerEndpointId) }) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptHost.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptHost.kt new file mode 100644 index 0000000..b227fb4 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptHost.kt @@ -0,0 +1,156 @@ +package com.vnidrop.app.feature.saveddevices + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +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.theme.LocalVniDropColors +import org.jetbrains.compose.resources.stringResource +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.pairing_accept +import vnidrop.shared.generated.resources.pairing_allow_body +import vnidrop.shared.generated.resources.pairing_allow_confirm +import vnidrop.shared.generated.resources.pairing_allow_title +import vnidrop.shared.generated.resources.pairing_decline +import vnidrop.shared.generated.resources.pairing_request_body +import vnidrop.shared.generated.resources.pairing_request_title + +@Composable +fun PairingPromptHost( + state: PairingPromptState, + onAccept: () -> Unit, + onDecline: () -> Unit, + onDismiss: () -> Unit, +) { + if (!state.enabled) return + val prompt = state.prompt ?: return + val colors = LocalVniDropColors.current + val deviceLabel = shortDeviceLabel(prompt.peerEndpointId()) + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = false, + ), + ) { + Surface( + modifier = Modifier.padding(24.dp).widthIn(max = 440.dp).fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + color = colors.backgroundDialog, + shadowElevation = 16.dp, + ) { + Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Surface(shape = RoundedCornerShape(14.dp), color = colors.backgroundSelection) { + PlatformIcon( + AppIcon.ShieldCheck, + contentDescription = null, + tint = colors.brandLink, + modifier = Modifier.padding(11.dp).size(24.dp), + ) + } + when (prompt) { + is PairingPrompt.Eligibility -> { + Text( + stringResource(Res.string.pairing_allow_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Text( + stringResource(Res.string.pairing_allow_body), + style = MaterialTheme.typography.bodyLarge, + color = colors.foregroundLight, + ) + Text( + deviceLabel, + style = MaterialTheme.typography.bodySmall, + color = colors.foregroundLighter, + ) + PromptActions( + primary = stringResource(Res.string.pairing_allow_confirm), + secondary = stringResource(Res.string.pairing_decline), + busy = state.busy, + onPrimary = onAccept, + onSecondary = onDecline, + ) + } + is PairingPrompt.IncomingRequest -> { + Text( + stringResource(Res.string.pairing_request_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Text( + stringResource(Res.string.pairing_request_body, deviceLabel), + style = MaterialTheme.typography.bodyLarge, + color = colors.foregroundLight, + ) + PromptActions( + primary = stringResource(Res.string.pairing_accept), + secondary = stringResource(Res.string.pairing_decline), + busy = state.busy, + onPrimary = onAccept, + onSecondary = onDecline, + ) + } + } + } + } + } +} + +@Composable +private fun PromptActions( + primary: String, + secondary: String, + busy: Boolean, + onPrimary: () -> Unit, + onSecondary: () -> Unit, +) { + BoxWithConstraints(Modifier.fillMaxWidth()) { + if (maxWidth < 330.dp) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + PrimaryButton(primary, onPrimary, Modifier.fillMaxWidth(), !busy) + SecondaryButton(secondary, onSecondary, Modifier.fillMaxWidth(), !busy) + } + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + Spacer(Modifier.weight(1f)) + SecondaryButton(secondary, onSecondary, enabled = !busy) + Spacer(Modifier.width(10.dp)) + PrimaryButton(primary, onPrimary, enabled = !busy) + } + } + } +} + +private fun PairingPrompt.peerEndpointId(): String = when (this) { + is PairingPrompt.Eligibility -> peerEndpointId + is PairingPrompt.IncomingRequest -> peerEndpointId +} + +private fun shortDeviceLabel(endpointId: String): String = + if (endpointId.length <= 12) endpointId else endpointId.take(8) + "…" diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesPanel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesPanel.kt new file mode 100644 index 0000000..2f23c3c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesPanel.kt @@ -0,0 +1,250 @@ +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) + "…" diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModel.kt new file mode 100644 index 0000000..0e6be1e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModel.kt @@ -0,0 +1,224 @@ +package com.vnidrop.app.feature.saveddevices + +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.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.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 eligibilities: List = emptyList(), + val pendingRelationships: List = emptyList(), + val savedDevices: List = emptyList(), + val busyPeerIds: Set = 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 = _state.asStateFlow() + + private val effects = Channel(Channel.BUFFERED) + val effectFlow = effects.receiveAsFlow() + + init { + viewModelScope.launch { + preferencesRepository.preferences.collectLatest { preferences -> + val enabled = preferences.experimentalSavedDevicesEnabled + _state.update { it.copy(enabled = enabled) } + if (enabled) refresh() else _state.update { + SavedDevicesState(enabled = false) + } + } + } + viewModelScope.launch { + repository.signals.collect { signal -> + when (signal) { + CoreSignal.PairingChanged, + CoreSignal.TargetedTransferChanged -> if (_state.value.enabled) refresh() + is CoreSignal.ApprovalChanged, + is CoreSignal.ReceiverHistoryChanged, + is CoreSignal.TransfersChanged -> Unit + } + } + } + } + + fun rememberEligible(peerEndpointId: String) = mutatePeer(peerEndpointId) { + repository.requestSavedDevicePairing(peerEndpointId).map { } + } + + fun declineEligible(peerEndpointId: String) = mutatePeer(peerEndpointId) { + repository.declinePairingEligibility(peerEndpointId) + } + + fun acceptIncoming(peerEndpointId: String) = mutatePeer(peerEndpointId) { + repository.respondToDevicePairing(peerEndpointId, accepted = true).map { } + } + + fun declineIncoming(peerEndpointId: String) = mutatePeer(peerEndpointId) { + repository.respondToDevicePairing(peerEndpointId, accepted = false).map { } + } + + fun openLabelEditor(peerEndpointId: String) { + val current = _state.value.savedDevices.firstOrNull { it.endpointId == peerEndpointId } + _state.update { + it.copy(labelingPeerId = peerEndpointId, labelDraft = current?.localLabel.orEmpty()) + } + } + + fun setLabelDraft(value: String) = _state.update { it.copy(labelDraft = value) } + + fun dismissLabelEditor() { + if (_state.value.labelingPeerId !in _state.value.busyPeerIds) { + _state.update { it.copy(labelingPeerId = null, labelDraft = "") } + } + } + + fun saveLabel() { + val peerId = _state.value.labelingPeerId ?: return + val label = _state.value.labelDraft.trim().ifBlank { null } + mutatePeer(peerId) { + repository.setSavedDeviceLabel(peerId, label).onSuccess { + messages.tryShow(UiMessage(UiText.Resource(Res.string.saved_devices_labeled), UiMessageTone.Success)) + } + } + _state.update { it.copy(labelingPeerId = null, labelDraft = "") } + } + + fun clearLabel() { + val peerId = _state.value.labelingPeerId ?: return + mutatePeer(peerId) { + repository.setSavedDeviceLabel(peerId, null).onSuccess { + messages.tryShow(UiMessage(UiText.Resource(Res.string.saved_devices_labeled), UiMessageTone.Success)) + } + } + _state.update { it.copy(labelingPeerId = null, labelDraft = "") } + } + + fun forget(peerEndpointId: String) = mutatePeer(peerEndpointId) { + repository.forgetSavedDevice(peerEndpointId).onSuccess { + messages.tryShow(UiMessage(UiText.Resource(Res.string.saved_devices_forgotten), UiMessageTone.Success)) + } + } + + fun block(peerEndpointId: String) = mutatePeer(peerEndpointId) { + repository.blockDevice(peerEndpointId).onSuccess { + messages.tryShow(UiMessage(UiText.Resource(Res.string.saved_devices_blocked), UiMessageTone.Success)) + } + } + + 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) { + 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) } + viewModelScope.launch { + block().fold( + onSuccess = { refresh() }, + onFailure = messages::error, + ) + _state.update { it.copy(busyPeerIds = it.busyPeerIds - peerEndpointId) } + } + } + + private suspend fun refresh() { + if (!_state.value.enabled) return + val eligibilities = repository.listPairingEligibilities().getOrElse { + messages.error(it) + return + } + val relationships = repository.listDeviceRelationships().getOrElse { + messages.error(it) + return + } + val saved = repository.listSavedDevices().getOrElse { + messages.error(it) + return + } + _state.update { + it.copy( + eligibilities = eligibilities.sortedByDescending(PairingEligibilityModel::createdAt), + pendingRelationships = relationships.filter { + it.state == DeviceRelationshipStateModel.PendingIncoming || + it.state == DeviceRelationshipStateModel.PendingOutgoing + }.sortedByDescending(DeviceRelationshipModel::updatedAt), + savedDevices = saved.sortedByDescending(SavedDeviceModel::createdAt), + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferCoordinator.kt new file mode 100644 index 0000000..4266f32 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferCoordinator.kt @@ -0,0 +1,122 @@ +package com.vnidrop.app.feature.saveddevices + +import com.vnidrop.app.core.CoreGateway +import com.vnidrop.app.core.CoreSignal +import com.vnidrop.app.core.FileSystemService +import com.vnidrop.app.core.PendingTargetedOfferModel +import com.vnidrop.app.core.ReceiveFolder +import com.vnidrop.app.core.TargetedOfferResponseModel +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.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.update +import kotlinx.coroutines.launch +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.receive_completed + +data class TargetedOfferState( + val enabled: Boolean = false, + val pending: List = emptyList(), + val respondingIds: Set = emptySet(), +) { + val current: PendingTargetedOfferModel? + get() = pending.firstOrNull() +} + +/** + * Foreground interrupt for pending targeted offers. Approve pulls by transfer id + * through the configured receive sink (MediaStore Downloads on Android). + */ +class TargetedOfferCoordinator( + private val repository: CoreGateway, + private val fileSystemService: FileSystemService, + private val preferencesRepository: PreferencesRepository, + private val messages: UiMessageController, + private val scope: CoroutineScope, +) { + private val _state = MutableStateFlow(TargetedOfferState()) + val state: StateFlow = _state.asStateFlow() + + private var receiveFolder: ReceiveFolder? = null + + init { + scope.launch { + preferencesRepository.preferences.collectLatest { preferences -> + receiveFolder = fileSystemService.effectiveReceiveFolder(preferences.receiveFolder) + val enabled = preferences.experimentalSavedDevicesEnabled + _state.update { it.copy(enabled = enabled) } + if (enabled) refresh() else _state.update { it.copy(pending = emptyList()) } + } + } + scope.launch { + repository.signals.collect { signal -> + when (signal) { + CoreSignal.TargetedTransferChanged -> if (_state.value.enabled) refresh() + CoreSignal.PairingChanged, + is CoreSignal.ApprovalChanged, + is CoreSignal.ReceiverHistoryChanged, + is CoreSignal.TransfersChanged -> Unit + } + } + } + } + + fun accept(transferId: String) = respond(transferId, accepted = true) + + fun decline(transferId: String) = respond(transferId, accepted = false) + + private fun respond(transferId: String, accepted: Boolean) { + if (transferId in _state.value.respondingIds) return + _state.update { it.copy(respondingIds = it.respondingIds + transferId) } + scope.launch { + val result = repository.respondToTargetedOffer(transferId, accepted) + result.fold( + onSuccess = { response -> + if (accepted && response is TargetedOfferResponseModel.Approved) { + pull(response.transferId) + } + refresh() + }, + onFailure = messages::error, + ) + _state.update { it.copy(respondingIds = it.respondingIds - transferId) } + } + } + + private suspend fun pull(transferId: String) { + val folder = receiveFolder ?: return + val sink = fileSystemService.createReceiveOutputSink(folder) + val result = if (sink != null) { + repository.receiveTargetedTransferWithOutputSinkV2(transferId, sink) + } else { + repository.receiveTargetedTransfer(transferId, folder.value) + } + result.fold( + onSuccess = { + messages.tryShow( + UiMessage(UiText.Resource(Res.string.receive_completed), UiMessageTone.Success), + ) + }, + onFailure = messages::error, + ) + } + + 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, + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferModalHost.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferModalHost.kt new file mode 100644 index 0000000..2a8f013 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferModalHost.kt @@ -0,0 +1,126 @@ +package com.vnidrop.app.feature.saveddevices + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +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.theme.LocalVniDropColors +import org.jetbrains.compose.resources.stringResource +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.offer_accept +import vnidrop.shared.generated.resources.offer_body +import vnidrop.shared.generated.resources.offer_decline +import vnidrop.shared.generated.resources.offer_title +import vnidrop.shared.generated.resources.saved_devices_unnamed + +@Composable +fun TargetedOfferModalHost( + state: TargetedOfferState, + 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) + Dialog( + onDismissRequest = {}, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + usePlatformDefaultWidth = false, + ), + ) { + Surface( + modifier = Modifier.padding(24.dp).widthIn(max = 440.dp).fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + color = colors.backgroundDialog, + shadowElevation = 16.dp, + ) { + Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Surface(shape = RoundedCornerShape(14.dp), color = colors.backgroundSelection) { + PlatformIcon( + AppIcon.Download, + contentDescription = null, + tint = colors.brandLink, + modifier = Modifier.padding(11.dp).size(24.dp), + ) + } + Text( + stringResource(Res.string.offer_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Text( + stringResource( + Res.string.offer_body, + device.ifBlank { stringResource(Res.string.saved_devices_unnamed) }, + offer.transferName.ifBlank { stringResource(Res.string.saved_devices_unnamed) }, + ), + style = MaterialTheme.typography.bodyLarge, + color = colors.foregroundLight, + ) + BoxWithConstraints(Modifier.fillMaxWidth()) { + if (maxWidth < 330.dp) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + PrimaryButton( + stringResource(Res.string.offer_accept), + { onAccept(offer.transferId) }, + Modifier.fillMaxWidth(), + !busy, + ) + SecondaryButton( + stringResource(Res.string.offer_decline), + { onDecline(offer.transferId) }, + Modifier.fillMaxWidth(), + !busy, + ) + } + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + Spacer(Modifier.weight(1f)) + SecondaryButton( + stringResource(Res.string.offer_decline), + { onDecline(offer.transferId) }, + enabled = !busy, + ) + Spacer(Modifier.width(10.dp)) + PrimaryButton( + stringResource(Res.string.offer_accept), + { onAccept(offer.transferId) }, + enabled = !busy, + ) + } + } + } + } + } + } +} + +private fun shortEndpoint(endpointId: String): String = + if (endpointId.length <= 12) endpointId else endpointId.take(8) + "…" diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt index 93b81a6..59d48f8 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt @@ -94,6 +94,8 @@ class SendViewModel( } refreshReceiverStatuses(signal.transferId) } + CoreSignal.PairingChanged, + CoreSignal.TargetedTransferChanged -> Unit } } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/ExperimentalSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/ExperimentalSettings.kt new file mode 100644 index 0000000..d511094 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/ExperimentalSettings.kt @@ -0,0 +1,66 @@ +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, + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt index 61835b5..36824dd 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt @@ -12,11 +12,7 @@ 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.about_title -import vnidrop.shared.generated.resources.appearance_dark_mode -import vnidrop.shared.generated.resources.appearance_light_mode -import vnidrop.shared.generated.resources.appearance_system_mode -import vnidrop.shared.generated.resources.appearance_title +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 @@ -26,12 +22,18 @@ import vnidrop.shared.generated.resources.relay_mode_local_only import vnidrop.shared.generated.resources.settings_title import vnidrop.shared.generated.resources.settings_network_title import vnidrop.shared.generated.resources.storage_title +import vnidrop.shared.generated.resources.about_title +import vnidrop.shared.generated.resources.appearance_dark_mode +import vnidrop.shared.generated.resources.appearance_light_mode +import vnidrop.shared.generated.resources.appearance_system_mode +import vnidrop.shared.generated.resources.appearance_title @Composable internal fun SettingsOverview( state: SettingsState, onSectionSelected: (SettingsSection) -> Unit, largeTitle: Boolean, + showExperimental: Boolean = false, ) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Text( @@ -80,6 +82,16 @@ 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, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt index e0f5320..20b8566 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt @@ -4,17 +4,39 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vnidrop.app.UiPlatform 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(viewModel: SettingsViewModel, windowClass: WindowClass) { +fun SettingsRoute( + viewModel: SettingsViewModel, + savedDevicesViewModel: SavedDevicesViewModel, + windowClass: WindowClass, +) { val state by viewModel.state.collectAsStateWithLifecycle() - val picker = rememberReceiveFolderPicker(viewModel::onReceiveFolderPicked, viewModel::onReceiveFolderPickFailed) + val savedDevicesState by savedDevicesViewModel.state.collectAsStateWithLifecycle() + val showExperimental = LocalUiPlatform.current == UiPlatform.Android + val folderPicker = rememberReceiveFolderPicker(viewModel::onReceiveFolderPicked, viewModel::onReceiveFolderPickFailed) + val sharePicker = rememberShareFilePicker( + savedDevicesViewModel::onFilesPicked, + savedDevicesViewModel::onFilePickFailed, + ) LaunchedEffect(viewModel) { viewModel.effectFlow.collect { effect -> when (effect) { - SettingsEffect.OpenReceiveFolderPicker -> picker.pickFolder() + SettingsEffect.OpenReceiveFolderPicker -> folderPicker.pickFolder() + } + } + } + LaunchedEffect(savedDevicesViewModel) { + savedDevicesViewModel.effectFlow.collect { effect -> + when (effect) { + SavedDevicesEffect.OpenFilePicker -> sharePicker.pickFiles() } } } @@ -32,6 +54,21 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) { 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, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt index 19f9549..7b31dd8 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt @@ -8,20 +8,37 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.vnidrop.app.ui.state.WindowClass 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 @Composable fun SettingsScreen( state: SettingsState, windowClass: WindowClass, + modifier: Modifier = Modifier, onSectionSelected: (SettingsSection) -> Unit, onUsernameChanged: (String) -> Unit, onThemeModeChanged: (ThemeMode) -> Unit, 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, @@ -41,17 +58,23 @@ fun SettingsScreen( ) { if (windowClass == WindowClass.Desktop) { Row( - modifier = Modifier.fillMaxWidth(), + modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(24.dp), ) { Column(Modifier.widthIn(min = 280.dp, max = 340.dp)) { - SettingsOverview(state, onSectionSelected, largeTitle = false) + SettingsOverview( + state, + onSectionSelected, + largeTitle = false, + showExperimental = showExperimental, + ) } Column(Modifier.weight(1f)) { SettingsSectionContent( state = state, windowClass = windowClass, - section = state.selectedSection.takeUnless { it == SettingsSection.Overview } ?: SettingsSection.Preferences, + section = state.selectedSection.takeUnless { it == SettingsSection.Overview } + ?: SettingsSection.Preferences, onBack = {}, showBack = false, onSectionSelected = onSectionSelected, @@ -60,6 +83,20 @@ 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, @@ -81,7 +118,12 @@ fun SettingsScreen( } } else { when (state.selectedSection) { - SettingsSection.Overview -> SettingsOverview(state, onSectionSelected, largeTitle = true) + SettingsSection.Overview -> SettingsOverview( + state, + onSectionSelected, + largeTitle = true, + showExperimental = showExperimental, + ) else -> SettingsSectionContent( state = state, windowClass = windowClass, @@ -102,6 +144,20 @@ 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, @@ -136,6 +192,20 @@ 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, @@ -168,6 +238,25 @@ 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, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt index 830166d..9d578f0 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt @@ -58,6 +58,7 @@ enum class SettingsSection { Network, Notifications, Storage, + Experimental, About, BugReport, } @@ -97,6 +98,7 @@ 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 = "", @@ -160,6 +162,7 @@ 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) { @@ -568,6 +571,12 @@ class SettingsViewModel( } } + fun setExperimentalSavedDevicesEnabled(enabled: Boolean) { + viewModelScope.launch { + preferencesRepository.setExperimentalSavedDevicesEnabled(enabled) + } + } + fun openNotificationSettings() { viewModelScope.launch { enableNotificationsAfterSettings = true diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/notifications/TransferNotificationCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/notifications/TransferNotificationCoordinator.kt index 992a69c..6d3a1b0 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/notifications/TransferNotificationCoordinator.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/notifications/TransferNotificationCoordinator.kt @@ -106,7 +106,9 @@ class TransferNotificationCoordinator( when (signal) { is CoreSignal.ReceiverHistoryChanged -> syncReceivers(signal.transferId) is CoreSignal.TransfersChanged -> syncReceivers(signal.transferId) - is CoreSignal.ApprovalChanged -> Unit + is CoreSignal.ApprovalChanged, + CoreSignal.PairingChanged, + CoreSignal.TargetedTransferChanged -> Unit } } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt index bc5c31d..19f61cf 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt @@ -27,6 +27,8 @@ 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( @@ -34,6 +36,7 @@ class AppPreferencesDefaults( val receiveFolder: ReceiveFolder, val themeMode: ThemeMode, val notificationsEnabled: Boolean = false, + val experimentalSavedDevicesEnabled: Boolean = false, ) interface PreferencesRepository { @@ -44,6 +47,7 @@ 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 } @@ -84,6 +88,8 @@ class AppPreferencesRepository( mode = relayMode, relayUrls = relayUrls, ), + experimentalSavedDevicesEnabled = prefs[PreferenceKeys.ExperimentalSavedDevicesEnabled] + ?: defaults.experimentalSavedDevicesEnabled, ) } @@ -117,6 +123,12 @@ 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 @@ -149,6 +161,7 @@ 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") diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/core/CoreEventSignalsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/core/CoreEventSignalsTest.kt new file mode 100644 index 0000000..0f2ac74 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/core/CoreEventSignalsTest.kt @@ -0,0 +1,30 @@ +package com.vnidrop.app.core + +import kotlin.test.Test +import kotlin.test.assertEquals + +class CoreEventSignalsTest { + @Test + fun pairingPhaseWakesWithoutTransferId() { + assertEquals( + listOf(CoreSignal.PairingChanged), + signalsForCoreEvent("pairing", transferId = null), + ) + } + + @Test + fun targetedTransferPhaseWakesWithoutTransferId() { + assertEquals( + listOf(CoreSignal.TargetedTransferChanged), + signalsForCoreEvent("targeted_transfer", transferId = null), + ) + } + + @Test + fun invitationApprovalStillEmitsTransferScopedSignal() { + assertEquals( + listOf(CoreSignal.ApprovalChanged(42uL)), + signalsForCoreEvent("approval", transferId = 42uL), + ) + } +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/core/SavedDeviceGatewayFakeTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/core/SavedDeviceGatewayFakeTest.kt new file mode 100644 index 0000000..ac46d35 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/core/SavedDeviceGatewayFakeTest.kt @@ -0,0 +1,43 @@ +package com.vnidrop.app.core + +import com.vnidrop.app.support.FakeCoreGateway +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking +import uniffi.vnidrop.ShareSource +import uniffi.vnidrop.SourceKind + +class SavedDeviceGatewayFakeTest { + @Test + fun fakeGatewayExposesSavedDeviceAndTargetedSeams() = runBlocking { + val gateway = FakeCoreGateway() + gateway.savedDevices = listOf( + SavedDeviceModel( + endpointId = "peer-1", + localLabel = "Kitchen", + remoteDisplayName = null, + createdAt = 1L, + lastAuthenticatedAt = null, + ), + ) + gateway.respondTargetedResult = Result.success( + TargetedOfferResponseModel.Approved("transfer-1"), + ) + + assertEquals("Kitchen", gateway.listSavedDevices().getOrThrow().single().localLabel) + assertEquals( + TargetedOfferResponseModel.Approved("transfer-1"), + gateway.respondToTargetedOffer("transfer-1", accepted = true).getOrThrow(), + ) + assertTrue( + gateway.createTargetedTransfer( + receiverEndpointId = "peer-1", + sources = listOf( + ShareSource(SourceKind.PATH, "/tmp/a.txt", "a.txt", false), + ), + transferName = "a.txt", + ).isFailure, + ) + } +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptCoordinatorTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptCoordinatorTest.kt new file mode 100644 index 0000000..9121abe --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptCoordinatorTest.kt @@ -0,0 +1,150 @@ +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.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class PairingPromptCoordinatorTest { + @Test + fun experimentalOffDoesNotPromptOnEligibility() = runTest { + val core = FakeCoreGateway().apply { + pairingEligibilities = listOf(eligibility("peer-a")) + } + val coordinator = PairingPromptCoordinator( + core, + preferences(enabled = false), + UiMessageController(), + backgroundScope, + ) + runCurrent() + advanceUntilIdle() + assertNull(coordinator.state.value.prompt) + } + + @Test + fun acceptEligibilityRequestsPairing() = runTest { + val core = FakeCoreGateway().apply { + pairingEligibilities = listOf(eligibility("peer-a")) + } + val coordinator = PairingPromptCoordinator( + core, + preferences(enabled = true), + UiMessageController(), + backgroundScope, + ) + runCurrent() + advanceUntilIdle() + assertEquals(PairingPrompt.Eligibility("peer-a"), coordinator.state.value.prompt) + + coordinator.accept() + runCurrent() + advanceUntilIdle() + assertEquals(listOf("peer-a"), core.requestedPairings) + } + + @Test + fun declineEligibilityConsumesWithoutRequest() = runTest { + val core = FakeCoreGateway().apply { + pairingEligibilities = listOf(eligibility("peer-a")) + } + val coordinator = PairingPromptCoordinator( + core, + preferences(enabled = true), + UiMessageController(), + backgroundScope, + ) + runCurrent() + advanceUntilIdle() + coordinator.decline() + runCurrent() + advanceUntilIdle() + assertTrue(core.requestedPairings.isEmpty()) + assertTrue(core.pairingEligibilities.none { it.peerEndpointId == "peer-a" }) + assertNull(coordinator.state.value.prompt) + } + + @Test + fun dismissKeepsEligibilityForSavedDevicesArea() = runTest { + val core = FakeCoreGateway().apply { + pairingEligibilities = listOf(eligibility("peer-a")) + } + val coordinator = PairingPromptCoordinator( + core, + preferences(enabled = true), + UiMessageController(), + backgroundScope, + ) + runCurrent() + advanceUntilIdle() + coordinator.dismiss() + runCurrent() + advanceUntilIdle() + assertNull(coordinator.state.value.prompt) + assertEquals(1, core.pairingEligibilities.size) + } + + @Test + fun incomingPairingRequestAcceptsViaRespond() = runTest { + val core = FakeCoreGateway().apply { + deviceRelationships = listOf(incoming("peer-b")) + } + val coordinator = PairingPromptCoordinator( + core, + preferences(enabled = true), + UiMessageController(), + backgroundScope, + ) + runCurrent() + advanceUntilIdle() + assertIs(coordinator.state.value.prompt) + + coordinator.accept() + runCurrent() + advanceUntilIdle() + assertEquals(listOf("peer-b" to true), core.pairingResponses) + } + + private fun eligibility(peer: String) = PairingEligibilityModel( + peerEndpointId = peer, + sessionId = "session", + protocolVersion = 1u, + createdAt = 1L, + expiresAt = 2L, + ) + + private fun incoming(peer: String) = DeviceRelationshipModel( + remoteEndpointId = peer, + state = DeviceRelationshipStateModel.PendingIncoming, + generation = 1u, + minimumProtocolVersion = 1u, + 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, + ), + ) +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModelTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModelTest.kt new file mode 100644 index 0000000..0c1687c --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModelTest.kt @@ -0,0 +1,137 @@ +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 +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +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.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SavedDevicesViewModelTest { + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun labelForgetAndBlockUpdateGateway() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + 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() + advanceUntilIdle() + assertEquals(1, viewModel.state.value.savedDevices.size) + + viewModel.openLabelEditor("peer-1") + viewModel.setLabelDraft("Kitchen") + viewModel.saveLabel() + runCurrent() + advanceUntilIdle() + assertEquals(listOf>("peer-1" to "Kitchen"), core.labeledDevices.toList()) + assertEquals("Kitchen", viewModel.state.value.savedDevices.single().localLabel) + + viewModel.openLabelEditor("peer-1") + viewModel.clearLabel() + runCurrent() + advanceUntilIdle() + assertEquals(listOf>("peer-1" to "Kitchen", "peer-1" to null), core.labeledDevices.toList()) + assertNull(viewModel.state.value.savedDevices.single().localLabel) + + viewModel.forget("peer-1") + runCurrent() + advanceUntilIdle() + assertEquals(listOf("peer-1"), core.forgottenDevices.toList()) + assertTrue(viewModel.state.value.savedDevices.isEmpty()) + + core.savedDevices = listOf(device("peer-2", label = "Desk")) + viewModel.block("peer-2") + runCurrent() + advanceUntilIdle() + assertEquals(listOf("peer-2"), core.blockedPeers.toList()) + } + + @Test + fun sendFromSavedDeviceCreatesTargetedTransfer() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + 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, + remoteDisplayName = null, + 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, + ), + ) +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferCoordinatorTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferCoordinatorTest.kt new file mode 100644 index 0000000..3a99ca8 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferCoordinatorTest.kt @@ -0,0 +1,109 @@ +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.TargetedOfferResponseModel +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.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class TargetedOfferCoordinatorTest { + @Test + fun acceptApprovesAndPullsByTransferId() = runTest { + val core = FakeCoreGateway().apply { + pendingTargetedOffers = listOf(offer("transfer-1")) + respondTargetedResult = Result.success(TargetedOfferResponseModel.Approved("transfer-1")) + receiveResult = Result.success(Unit) + } + val coordinator = TargetedOfferCoordinator( + core, + FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")), + preferences(enabled = true), + UiMessageController(), + backgroundScope, + ) + runCurrent() + advanceUntilIdle() + assertEquals("transfer-1", coordinator.state.value.current?.transferId) + + coordinator.accept("transfer-1") + runCurrent() + advanceUntilIdle() + assertEquals(listOf("transfer-1" to true), core.respondedTargetedOffers) + assertEquals(listOf("transfer-1"), core.receivedTargetedTransferIds) + } + + @Test + fun declineDoesNotReceive() = runTest { + val core = FakeCoreGateway().apply { + pendingTargetedOffers = listOf(offer("transfer-2")) + respondTargetedResult = Result.success(TargetedOfferResponseModel.Declined) + } + val coordinator = TargetedOfferCoordinator( + core, + FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")), + preferences(enabled = true), + UiMessageController(), + backgroundScope, + ) + runCurrent() + advanceUntilIdle() + coordinator.decline("transfer-2") + runCurrent() + advanceUntilIdle() + assertEquals(listOf("transfer-2" to false), core.respondedTargetedOffers) + assertTrue(core.receivedTargetedTransferIds.isEmpty()) + } + + @Test + fun experimentalOffIgnoresPendingOffers() = runTest { + val core = FakeCoreGateway().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()) + } + + private fun offer(id: String) = PendingTargetedOfferModel( + transferId = id, + senderEndpointId = "sender", + receiverEndpointId = "receiver", + manifestId = "manifest", + contentHash = "hash", + transferName = "Photos", + fileCount = 1u, + totalSize = 10u, + protocolVersion = 1u, + receivedAt = 1L, + ) + + private fun preferences(enabled: Boolean) = FakePreferencesRepository( + AppPreferences( + username = "User", + receiveFolder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp"), + themeMode = ThemeMode.System, + notificationsEnabled = false, + experimentalSavedDevicesEnabled = enabled, + ), + ) +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt index c8f7b55..6f9ed77 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -4,16 +4,22 @@ import com.vnidrop.app.core.CoreGateway import com.vnidrop.app.core.CoreStorageUsageModel import com.vnidrop.app.core.CoreSignal import com.vnidrop.app.core.CoreState +import com.vnidrop.app.core.DeviceRelationshipModel import com.vnidrop.app.core.FileSystemService 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.ReceiveFolder import com.vnidrop.app.core.ReceivedArtifactModel import com.vnidrop.app.core.ReceivedStorageInspection import com.vnidrop.app.core.RelaySettings import com.vnidrop.app.core.ReceiverRequestModel +import com.vnidrop.app.core.SavedDeviceModel import com.vnidrop.app.core.Share import com.vnidrop.app.core.ShareAccessPolicy +import com.vnidrop.app.core.TargetedOfferResponseModel +import com.vnidrop.app.core.TargetedTransferModel import com.vnidrop.app.core.TicketInspectionModel import com.vnidrop.app.core.Transfer import com.vnidrop.app.core.TransferDirection @@ -196,6 +202,103 @@ class FakeCoreGateway : CoreGateway { return responseResult } override suspend fun refresh() = Result.success(Unit) + + var pairingEligibilities: List = emptyList() + var deviceRelationships: List = emptyList() + var savedDevices: List = emptyList() + var blockedDevices: List = emptyList() + var pendingTargetedOffers: List = emptyList() + var targetedTransfers: List = emptyList() + var respondTargetedResult: Result = + Result.success(TargetedOfferResponseModel.Declined) + var createTargetedResult: Result = + Result.failure(UnsupportedOperationException()) + val forgottenDevices = mutableListOf() + val blockedPeers = mutableListOf() + val labeledDevices = mutableListOf>() + + override suspend fun listPairingEligibilities() = Result.success(pairingEligibilities) + override suspend fun declinePairingEligibility(peerEndpointId: String): Result { + pairingEligibilities = pairingEligibilities.filterNot { it.peerEndpointId == peerEndpointId } + return Result.success(Unit) + } + val requestedPairings = mutableListOf() + val pairingResponses = mutableListOf>() + var requestPairingResult: Result = Result.success(true) + var respondPairingResult: Result = Result.success(true) + val createdTargetedTransfers = mutableListOf, String?>>() + val receivedTargetedTransferIds = mutableListOf() + val respondedTargetedOffers = mutableListOf>() + + override suspend fun requestSavedDevicePairing(peerEndpointId: String): Result { + requestedPairings += peerEndpointId + return requestPairingResult + } + override suspend fun respondToDevicePairing(peerEndpointId: String, accepted: Boolean): Result { + pairingResponses += peerEndpointId to accepted + return respondPairingResult.map { accepted } + } + override suspend fun listDeviceRelationships() = Result.success(deviceRelationships) + override suspend fun listSavedDevices() = Result.success(savedDevices) + override suspend fun setSavedDeviceLabel(peerEndpointId: String, label: String?): Result { + labeledDevices += peerEndpointId to label + savedDevices = savedDevices.map { + if (it.endpointId == peerEndpointId) it.copy(localLabel = label) else it + } + return Result.success(Unit) + } + override suspend fun forgetSavedDevice(peerEndpointId: String): Result { + forgottenDevices += peerEndpointId + savedDevices = savedDevices.filterNot { it.endpointId == peerEndpointId } + return Result.success(Unit) + } + override suspend fun blockDevice(peerEndpointId: String): Result { + blockedPeers += peerEndpointId + blockedDevices = (blockedDevices + peerEndpointId).distinct() + return Result.success(Unit) + } + override suspend fun unblockDevice(peerEndpointId: String): Result { + blockedDevices = blockedDevices.filterNot { it == peerEndpointId } + return Result.success(Unit) + } + override suspend fun listBlockedDevices() = Result.success(blockedDevices) + override suspend fun listPendingTargetedOffers() = Result.success(pendingTargetedOffers) + override suspend fun respondToTargetedOffer(transferId: String, accepted: Boolean): Result { + respondedTargetedOffers += transferId to accepted + return respondTargetedResult + } + override suspend fun createTargetedTransfer( + receiverEndpointId: String, + sources: List, + transferName: String?, + ): Result { + createdTargetedTransfers += Triple(receiverEndpointId, sources, transferName) + return createTargetedResult + } + override suspend fun getTargetedTransfer(id: String) = + Result.success(targetedTransfers.firstOrNull { it.id == id }) + override suspend fun listTargetedTransfers() = Result.success(targetedTransfers) + override suspend fun receiveTargetedTransfer(transferId: String, outputDir: String): Result { + receivedTargetedTransferIds += transferId + return receiveResult + } + override suspend fun receiveTargetedTransferWithOutputSink( + transferId: String, + outputSink: ReceiveOutputSink, + ): Result { + receivedTargetedTransferIds += transferId + return receiveResult + } + override suspend fun receiveTargetedTransferWithOutputSinkV2( + transferId: String, + outputSink: ReceiveOutputSinkV2, + ): Result { + receivedTargetedTransferIds += transferId + return receiveResult + } + override suspend fun resumeTargetedTransfer(id: String, outputDir: String) = receiveResult + override suspend fun cancelTargetedTransfer(id: String) = Result.success(Unit) + override suspend fun deleteTargetedTransfer(id: String) = Result.success(Unit) } class FakePreferencesRepository( @@ -210,6 +313,9 @@ 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) } @@ -289,6 +395,23 @@ class FakeFileSystemService( } return repository.shareSources(sources, transferName, senderName, accessPolicy) } + + override suspend fun createTargetedTransferFromPickedFiles( + repository: CoreGateway, + receiverEndpointId: String, + files: List, + transferName: String?, + ): Result { + 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) + } } class FakeFilePreviewRepository : FilePreviewRepository { diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt index a6d17e1..3b10812 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt @@ -61,15 +61,26 @@ private class JvmFileSystemService : FileSystemService { accessPolicy: ShareAccessPolicy, ): Result { require(files.isNotEmpty()) { "Select at least one file to share" } - val sources = files.map { file -> - uniffi.vnidrop.ShareSource( - kind = uniffi.vnidrop.SourceKind.PATH, - value = file.value, - displayName = file.displayName, - isDirectory = file.isDirectory || File(file.value).isDirectory, - ) - } - return repository.shareSources(sources, transferName, senderName, accessPolicy) + return repository.shareSources(pathShareSources(files), transferName, senderName, accessPolicy) + } + + override suspend fun createTargetedTransferFromPickedFiles( + repository: CoreGateway, + receiverEndpointId: String, + files: List, + transferName: String?, + ): Result { + require(files.isNotEmpty()) { "Select at least one file to share" } + return repository.createTargetedTransfer(receiverEndpointId, pathShareSources(files), transferName) + } + + private fun pathShareSources(files: List) = files.map { file -> + uniffi.vnidrop.ShareSource( + kind = uniffi.vnidrop.SourceKind.PATH, + value = file.value, + displayName = file.displayName, + isDirectory = file.isDirectory || File(file.value).isDirectory, + ) } } diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt index 0959281..02ce5dd 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt @@ -129,6 +129,15 @@ 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(