feat(shared): Android experimental saved-devices KMP UI

Gate pairing and targeted transfers behind Settings → Experimental, with
CoreGateway wake-ups, in-flow prompts, and one Android dogfood round-trip.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-11 18:09:37 +02:00
parent edf7426672
commit eabc2754a7
43 changed files with 2993 additions and 36 deletions

View File

@@ -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 contracts 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.

View File

@@ -142,7 +142,23 @@ private class AndroidFileSystemService(
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> = runCatching {
): Result<Share> = withAndroidShareSources(files) { sources ->
repository.shareSources(sources, transferName, senderName, accessPolicy).getOrThrow()
}
override suspend fun createTargetedTransferFromPickedFiles(
repository: CoreGateway,
receiverEndpointId: String,
files: List<PickedShareFile>,
transferName: String?,
): Result<TargetedTransferModel> = withAndroidShareSources(files) { sources ->
repository.createTargetedTransfer(receiverEndpointId, sources, transferName).getOrThrow()
}
private suspend fun <T> withAndroidShareSources(
files: List<PickedShareFile>,
block: suspend (List<uniffi.vnidrop.ShareSource>) -> T,
): Result<T> = runCatching {
require(files.isNotEmpty()) { "Select at least one file to share" }
// Android cannot pass a directory as a single FD. Expand SAF trees into
// individual document files with relative collection paths, then open FDs.
@@ -163,7 +179,7 @@ private class AndroidFileSystemService(
isDirectory = false,
)
}
repository.shareSources(sources, transferName, senderName, accessPolicy).getOrThrow()
block(sources)
} finally {
descriptors.forEach { it.close() }
}

View File

@@ -100,6 +100,9 @@
<string name="error_starting_up">VniDrop startet noch. Öffnen Sie die Einladung gleich erneut.</string>
<string name="error_storage_full">Zum Speichern dieser Übertragung ist nicht genügend Speicherplatz vorhanden. Geben Sie Speicherplatz frei und versuchen Sie es erneut.</string>
<string name="error_transfer">Die Übertragungsdaten konnten nicht verarbeitet werden. Bitten Sie den Absender, sie erneut zu teilen.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Empfängername</string>
<string name="field_sender_name">Absendername</string>
<string name="field_transfer_name">Übertragungsname</string>
@@ -317,6 +320,28 @@
<string name="offer_body">%1$s möchte Ihnen „%2$s“ senden.</string>
<string name="offer_accept">Empfangen</string>
<string name="offer_decline">Ablehnen</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>
<string name="saved_devices_accept_pairing_action">Accept</string>
<string name="saved_devices_send_action">Send files</string>
<string name="saved_devices_label_action">Label</string>
<string name="saved_devices_forget_action">Forget</string>
<string name="saved_devices_block_action">Block</string>
<string name="saved_devices_label_title">Device label</string>
<string name="saved_devices_label_placeholder">Label</string>
<string name="saved_devices_label_save">Save</string>
<string name="saved_devices_label_clear">Clear label</string>
<string name="saved_devices_pending_outgoing">Waiting for the other device</string>
<string name="saved_devices_pending_incoming">Wants to remember this device</string>
<string name="saved_devices_send_started">Transfer offer sent</string>
<string name="saved_devices_forgotten">Saved device forgotten</string>
<string name="saved_devices_blocked">Device blocked</string>
<string name="saved_devices_labeled">Label updated</string>
<plurals name="transfer_file_count">
<item quantity="one">%1$d Datei</item>
<item quantity="other">%1$d Dateien</item>

View File

@@ -100,6 +100,9 @@
<string name="error_starting_up">VniDrop todavía se está iniciando. Vuelva a abrir la invitación en un momento.</string>
<string name="error_storage_full">No hay suficiente espacio de almacenamiento para guardar esta transferencia. Libere espacio e inténtelo de nuevo.</string>
<string name="error_transfer">No se pudieron procesar los datos de la transferencia. Pida al remitente que vuelva a compartirlos.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Nombre del destinatario</string>
<string name="field_sender_name">Nombre del remitente</string>
<string name="field_transfer_name">Nombre de la transferencia</string>
@@ -317,6 +320,28 @@
<string name="offer_body">%1$s quiere enviarte «%2$s».</string>
<string name="offer_accept">Recibir</string>
<string name="offer_decline">Rechazar</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>
<string name="saved_devices_accept_pairing_action">Accept</string>
<string name="saved_devices_send_action">Send files</string>
<string name="saved_devices_label_action">Label</string>
<string name="saved_devices_forget_action">Forget</string>
<string name="saved_devices_block_action">Block</string>
<string name="saved_devices_label_title">Device label</string>
<string name="saved_devices_label_placeholder">Label</string>
<string name="saved_devices_label_save">Save</string>
<string name="saved_devices_label_clear">Clear label</string>
<string name="saved_devices_pending_outgoing">Waiting for the other device</string>
<string name="saved_devices_pending_incoming">Wants to remember this device</string>
<string name="saved_devices_send_started">Transfer offer sent</string>
<string name="saved_devices_forgotten">Saved device forgotten</string>
<string name="saved_devices_blocked">Device blocked</string>
<string name="saved_devices_labeled">Label updated</string>
<plurals name="transfer_file_count">
<item quantity="one">%1$d archivo</item>
<item quantity="other">%1$d archivos</item>

View File

@@ -100,6 +100,9 @@
<string name="error_starting_up">VniDrop démarre encore. Rouvrez linvitation dans un instant.</string>
<string name="error_storage_full">Lespace de stockage est insuffisant pour enregistrer ce transfert. Libérez de lespace et réessayez.</string>
<string name="error_transfer">Les données du transfert nont pas pu être traitées. Demandez à lexpéditeur de les partager à nouveau.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Nom du destinataire</string>
<string name="field_sender_name">Nom de lexpéditeur</string>
<string name="field_transfer_name">Nom du transfert</string>
@@ -317,6 +320,28 @@
<string name="offer_body">%1$s souhaite vous envoyer « %2$s ».</string>
<string name="offer_accept">Recevoir</string>
<string name="offer_decline">Refuser</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>
<string name="saved_devices_accept_pairing_action">Accept</string>
<string name="saved_devices_send_action">Send files</string>
<string name="saved_devices_label_action">Label</string>
<string name="saved_devices_forget_action">Forget</string>
<string name="saved_devices_block_action">Block</string>
<string name="saved_devices_label_title">Device label</string>
<string name="saved_devices_label_placeholder">Label</string>
<string name="saved_devices_label_save">Save</string>
<string name="saved_devices_label_clear">Clear label</string>
<string name="saved_devices_pending_outgoing">Waiting for the other device</string>
<string name="saved_devices_pending_incoming">Wants to remember this device</string>
<string name="saved_devices_send_started">Transfer offer sent</string>
<string name="saved_devices_forgotten">Saved device forgotten</string>
<string name="saved_devices_blocked">Device blocked</string>
<string name="saved_devices_labeled">Label updated</string>
<plurals name="transfer_file_count">
<item quantity="one">%1$d fichier</item>
<item quantity="other">%1$d fichiers</item>

View File

@@ -100,6 +100,9 @@
<string name="error_starting_up">VniDrop è ancora in fase di avvio. Riapra linvito tra un momento.</string>
<string name="error_storage_full">Lo spazio di archiviazione non è sufficiente per salvare il trasferimento. Liberi spazio e riprovi.</string>
<string name="error_transfer">Non è stato possibile elaborare i dati del trasferimento. Chieda al mittente di condividerli di nuovo.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Nome del destinatario</string>
<string name="field_sender_name">Nome del mittente</string>
<string name="field_transfer_name">Nome del trasferimento</string>
@@ -317,6 +320,28 @@
<string name="offer_body">%1$s vuole inviarti «%2$s».</string>
<string name="offer_accept">Ricevi</string>
<string name="offer_decline">Rifiuta</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>
<string name="saved_devices_accept_pairing_action">Accept</string>
<string name="saved_devices_send_action">Send files</string>
<string name="saved_devices_label_action">Label</string>
<string name="saved_devices_forget_action">Forget</string>
<string name="saved_devices_block_action">Block</string>
<string name="saved_devices_label_title">Device label</string>
<string name="saved_devices_label_placeholder">Label</string>
<string name="saved_devices_label_save">Save</string>
<string name="saved_devices_label_clear">Clear label</string>
<string name="saved_devices_pending_outgoing">Waiting for the other device</string>
<string name="saved_devices_pending_incoming">Wants to remember this device</string>
<string name="saved_devices_send_started">Transfer offer sent</string>
<string name="saved_devices_forgotten">Saved device forgotten</string>
<string name="saved_devices_blocked">Device blocked</string>
<string name="saved_devices_labeled">Label updated</string>
<plurals name="transfer_file_count">
<item quantity="one">%1$d file</item>
<item quantity="other">%1$d file</item>

View File

@@ -100,6 +100,9 @@
<string name="error_starting_up">VniDrop is nog aan het opstarten. Open de uitnodiging zo meteen opnieuw.</string>
<string name="error_storage_full">Er is onvoldoende opslagruimte om deze overdracht op te slaan. Maak ruimte vrij en probeer het opnieuw.</string>
<string name="error_transfer">De overdrachtsgegevens konden niet worden verwerkt. Vraag de afzender ze opnieuw te delen.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Naam van ontvanger</string>
<string name="field_sender_name">Naam van afzender</string>
<string name="field_transfer_name">Naam van overdracht</string>
@@ -317,6 +320,28 @@
<string name="offer_body">%1$s wil je “%2$s” sturen.</string>
<string name="offer_accept">Ontvangen</string>
<string name="offer_decline">Weigeren</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>
<string name="saved_devices_accept_pairing_action">Accept</string>
<string name="saved_devices_send_action">Send files</string>
<string name="saved_devices_label_action">Label</string>
<string name="saved_devices_forget_action">Forget</string>
<string name="saved_devices_block_action">Block</string>
<string name="saved_devices_label_title">Device label</string>
<string name="saved_devices_label_placeholder">Label</string>
<string name="saved_devices_label_save">Save</string>
<string name="saved_devices_label_clear">Clear label</string>
<string name="saved_devices_pending_outgoing">Waiting for the other device</string>
<string name="saved_devices_pending_incoming">Wants to remember this device</string>
<string name="saved_devices_send_started">Transfer offer sent</string>
<string name="saved_devices_forgotten">Saved device forgotten</string>
<string name="saved_devices_blocked">Device blocked</string>
<string name="saved_devices_labeled">Label updated</string>
<plurals name="transfer_file_count">
<item quantity="one">%1$d bestand</item>
<item quantity="other">%1$d bestanden</item>

View File

@@ -100,6 +100,9 @@
<string name="error_starting_up">VniDrop jeszcze się uruchamia. Otwórz zaproszenie ponownie za chwilę.</string>
<string name="error_storage_full">Brakuje miejsca na zapisanie tego transferu. Zwolnij miejsce i spróbuj ponownie.</string>
<string name="error_transfer">Nie udało się przetworzyć danych transferu. Poproś nadawcę o ponowne udostępnienie.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Nazwa odbiorcy</string>
<string name="field_sender_name">Nazwa nadawcy</string>
<string name="field_transfer_name">Nazwa transferu</string>
@@ -317,6 +320,28 @@
<string name="offer_body">%1$s chce wysłać Ci „%2$s”.</string>
<string name="offer_accept">Odbierz</string>
<string name="offer_decline">Odrzuć</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>
<string name="saved_devices_accept_pairing_action">Accept</string>
<string name="saved_devices_send_action">Send files</string>
<string name="saved_devices_label_action">Label</string>
<string name="saved_devices_forget_action">Forget</string>
<string name="saved_devices_block_action">Block</string>
<string name="saved_devices_label_title">Device label</string>
<string name="saved_devices_label_placeholder">Label</string>
<string name="saved_devices_label_save">Save</string>
<string name="saved_devices_label_clear">Clear label</string>
<string name="saved_devices_pending_outgoing">Waiting for the other device</string>
<string name="saved_devices_pending_incoming">Wants to remember this device</string>
<string name="saved_devices_send_started">Transfer offer sent</string>
<string name="saved_devices_forgotten">Saved device forgotten</string>
<string name="saved_devices_blocked">Device blocked</string>
<string name="saved_devices_labeled">Label updated</string>
<plurals name="transfer_file_count">
<item quantity="one">%1$d plik</item>
<item quantity="few">%1$d pliki</item>

View File

@@ -100,6 +100,9 @@
<string name="error_starting_up">O VniDrop ainda está a iniciar. Abra o convite novamente dentro de momentos.</string>
<string name="error_storage_full">Não existe espaço de armazenamento suficiente para guardar esta transferência. Liberte espaço e tente novamente.</string>
<string name="error_transfer">Não foi possível processar os dados da transferência. Peça ao remetente para os partilhar novamente.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Nome do destinatário</string>
<string name="field_sender_name">Nome do remetente</string>
<string name="field_transfer_name">Nome da transferência</string>
@@ -317,6 +320,28 @@
<string name="offer_body">%1$s quer enviar-lhe “%2$s”.</string>
<string name="offer_accept">Receber</string>
<string name="offer_decline">Recusar</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>
<string name="saved_devices_accept_pairing_action">Accept</string>
<string name="saved_devices_send_action">Send files</string>
<string name="saved_devices_label_action">Label</string>
<string name="saved_devices_forget_action">Forget</string>
<string name="saved_devices_block_action">Block</string>
<string name="saved_devices_label_title">Device label</string>
<string name="saved_devices_label_placeholder">Label</string>
<string name="saved_devices_label_save">Save</string>
<string name="saved_devices_label_clear">Clear label</string>
<string name="saved_devices_pending_outgoing">Waiting for the other device</string>
<string name="saved_devices_pending_incoming">Wants to remember this device</string>
<string name="saved_devices_send_started">Transfer offer sent</string>
<string name="saved_devices_forgotten">Saved device forgotten</string>
<string name="saved_devices_blocked">Device blocked</string>
<string name="saved_devices_labeled">Label updated</string>
<plurals name="transfer_file_count">
<item quantity="one">%1$d ficheiro</item>
<item quantity="other">%1$d ficheiros</item>

View File

@@ -100,6 +100,9 @@
<string name="error_starting_up">VniDrop ещё запускается. Откройте приглашение снова через мгновение.</string>
<string name="error_storage_full">Недостаточно места для сохранения этой передачи. Освободите место и повторите попытку.</string>
<string name="error_transfer">Не удалось обработать данные передачи. Попросите отправителя поделиться ими снова.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Имя получателя</string>
<string name="field_sender_name">Имя отправителя</string>
<string name="field_transfer_name">Название передачи</string>
@@ -317,6 +320,28 @@
<string name="offer_body">%1$s хочет отправить вам «%2$s».</string>
<string name="offer_accept">Получить</string>
<string name="offer_decline">Отклонить</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>
<string name="saved_devices_accept_pairing_action">Accept</string>
<string name="saved_devices_send_action">Send files</string>
<string name="saved_devices_label_action">Label</string>
<string name="saved_devices_forget_action">Forget</string>
<string name="saved_devices_block_action">Block</string>
<string name="saved_devices_label_title">Device label</string>
<string name="saved_devices_label_placeholder">Label</string>
<string name="saved_devices_label_save">Save</string>
<string name="saved_devices_label_clear">Clear label</string>
<string name="saved_devices_pending_outgoing">Waiting for the other device</string>
<string name="saved_devices_pending_incoming">Wants to remember this device</string>
<string name="saved_devices_send_started">Transfer offer sent</string>
<string name="saved_devices_forgotten">Saved device forgotten</string>
<string name="saved_devices_blocked">Device blocked</string>
<string name="saved_devices_labeled">Label updated</string>
<plurals name="transfer_file_count">
<item quantity="one">%1$d файл</item>
<item quantity="few">%1$d файла</item>

View File

@@ -100,6 +100,9 @@
<string name="error_starting_up">VniDrop is still starting. Open the invitation again in a moment.</string>
<string name="error_storage_full">There is not enough storage space to save this transfer. Free up space and try again.</string>
<string name="error_transfer">The transfer data could not be processed. Ask the sender to share it again.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Receiver name</string>
<string name="field_sender_name">Sender name</string>
<string name="field_transfer_name">Transfer name</string>
@@ -317,6 +320,28 @@
<string name="offer_body">%1$s wants to send you “%2$s”.</string>
<string name="offer_accept">Receive</string>
<string name="offer_decline">Decline</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>
<string name="saved_devices_accept_pairing_action">Accept</string>
<string name="saved_devices_send_action">Send files</string>
<string name="saved_devices_label_action">Label</string>
<string name="saved_devices_forget_action">Forget</string>
<string name="saved_devices_block_action">Block</string>
<string name="saved_devices_label_title">Device label</string>
<string name="saved_devices_label_placeholder">Label</string>
<string name="saved_devices_label_save">Save</string>
<string name="saved_devices_label_clear">Clear label</string>
<string name="saved_devices_pending_outgoing">Waiting for the other device</string>
<string name="saved_devices_pending_incoming">Wants to remember this device</string>
<string name="saved_devices_send_started">Transfer offer sent</string>
<string name="saved_devices_forgotten">Saved device forgotten</string>
<string name="saved_devices_blocked">Device blocked</string>
<string name="saved_devices_labeled">Label updated</string>
<plurals name="transfer_file_count">
<item quantity="other">%1$d files</item>
<item quantity="one">%1$d file</item>

View File

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

View File

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

View File

@@ -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<CoreSignal> {
val signals = mutableListOf<CoreSignal>()
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
}

View File

@@ -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<List<ReceiverRequestModel>>
suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null): Result<Unit>
suspend fun refresh(): Result<Unit>
// Experimental saved devices / targeted transfers
suspend fun listPairingEligibilities(): Result<List<PairingEligibilityModel>>
suspend fun declinePairingEligibility(peerEndpointId: String): Result<Unit>
suspend fun requestSavedDevicePairing(peerEndpointId: String): Result<Boolean>
suspend fun respondToDevicePairing(peerEndpointId: String, accepted: Boolean): Result<Boolean>
suspend fun listDeviceRelationships(): Result<List<DeviceRelationshipModel>>
suspend fun listSavedDevices(): Result<List<SavedDeviceModel>>
suspend fun setSavedDeviceLabel(peerEndpointId: String, label: String?): Result<Unit>
suspend fun forgetSavedDevice(peerEndpointId: String): Result<Unit>
suspend fun blockDevice(peerEndpointId: String): Result<Unit>
suspend fun unblockDevice(peerEndpointId: String): Result<Unit>
suspend fun listBlockedDevices(): Result<List<String>>
suspend fun listPendingTargetedOffers(): Result<List<PendingTargetedOfferModel>>
suspend fun respondToTargetedOffer(transferId: String, accepted: Boolean): Result<TargetedOfferResponseModel>
suspend fun createTargetedTransfer(
receiverEndpointId: String,
sources: List<uniffi.vnidrop.ShareSource>,
transferName: String?,
): Result<TargetedTransferModel>
suspend fun getTargetedTransfer(id: String): Result<TargetedTransferModel?>
suspend fun listTargetedTransfers(): Result<List<TargetedTransferModel>>
suspend fun receiveTargetedTransfer(transferId: String, outputDir: String): Result<Unit>
suspend fun receiveTargetedTransferWithOutputSink(
transferId: String,
outputSink: ReceiveOutputSink,
): Result<Unit>
suspend fun receiveTargetedTransferWithOutputSinkV2(
transferId: String,
outputSink: ReceiveOutputSinkV2,
): Result<Unit>
suspend fun resumeTargetedTransfer(id: String, outputDir: String): Result<Unit>
suspend fun cancelTargetedTransfer(id: String): Result<Unit>
suspend fun deleteTargetedTransfer(id: String): Result<Unit>
}

View File

@@ -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<Unit> = runCore(::refreshSnapshot)
override suspend fun listPairingEligibilities(): Result<List<PairingEligibilityModel>> = runCore { activeCore ->
activeCore.listPairingEligibilities().map { it.toModel() }
}
override suspend fun declinePairingEligibility(peerEndpointId: String): Result<Unit> = runCore { activeCore ->
activeCore.declinePairingEligibility(peerEndpointId)
}
override suspend fun requestSavedDevicePairing(peerEndpointId: String): Result<Boolean> = runCore { activeCore ->
activeCore.requestSavedDevicePairing(peerEndpointId)
}
override suspend fun respondToDevicePairing(peerEndpointId: String, accepted: Boolean): Result<Boolean> =
runCore { activeCore ->
activeCore.respondToDevicePairing(peerEndpointId, accepted)
}
override suspend fun listDeviceRelationships(): Result<List<DeviceRelationshipModel>> = runCore { activeCore ->
activeCore.listDeviceRelationships().map { it.toModel() }
}
override suspend fun listSavedDevices(): Result<List<SavedDeviceModel>> = runCore { activeCore ->
activeCore.listSavedDevices().map { it.toModel() }
}
override suspend fun setSavedDeviceLabel(peerEndpointId: String, label: String?): Result<Unit> = runCore { activeCore ->
activeCore.setSavedDeviceLabel(peerEndpointId, label)
}
override suspend fun forgetSavedDevice(peerEndpointId: String): Result<Unit> = runCore { activeCore ->
activeCore.forgetSavedDevice(peerEndpointId)
}
override suspend fun blockDevice(peerEndpointId: String): Result<Unit> = runCore { activeCore ->
activeCore.blockDevice(peerEndpointId)
}
override suspend fun unblockDevice(peerEndpointId: String): Result<Unit> = runCore { activeCore ->
activeCore.unblockDevice(peerEndpointId)
}
override suspend fun listBlockedDevices(): Result<List<String>> = runCore { activeCore ->
activeCore.listBlockedDevices()
}
override suspend fun listPendingTargetedOffers(): Result<List<PendingTargetedOfferModel>> = runCore { activeCore ->
activeCore.listPendingTargetedOffers().map { it.toModel() }
}
override suspend fun respondToTargetedOffer(
transferId: String,
accepted: Boolean,
): Result<TargetedOfferResponseModel> = runCore { activeCore ->
activeCore.respondToTargetedOffer(transferId, accepted).toModel()
}
override suspend fun createTargetedTransfer(
receiverEndpointId: String,
sources: List<ShareSource>,
transferName: String?,
): Result<TargetedTransferModel> = 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<TargetedTransferModel?> = runCore { activeCore ->
activeCore.getTargetedTransfer(id)?.toModel()
}
override suspend fun listTargetedTransfers(): Result<List<TargetedTransferModel>> = runCore { activeCore ->
activeCore.listTargetedTransfers().map { it.toModel() }
}
override suspend fun receiveTargetedTransfer(transferId: String, outputDir: String): Result<Unit> =
runCore { activeCore ->
activeCore.receiveTargetedTransfer(transferId, outputDir)
}
override suspend fun receiveTargetedTransferWithOutputSink(
transferId: String,
outputSink: ReceiveOutputSink,
): Result<Unit> = runCore { activeCore ->
activeCore.receiveTargetedTransferWithOutputSink(transferId, outputSink)
}
override suspend fun receiveTargetedTransferWithOutputSinkV2(
transferId: String,
outputSink: ReceiveOutputSinkV2,
): Result<Unit> = runCore { activeCore ->
activeCore.receiveTargetedTransferWithOutputSinkV2(transferId, outputSink)
}
override suspend fun resumeTargetedTransfer(id: String, outputDir: String): Result<Unit> = runCore { activeCore ->
activeCore.resumeTargetedTransfer(id, outputDir)
}
override suspend fun cancelTargetedTransfer(id: String): Result<Unit> = runCore { activeCore ->
activeCore.cancelTargetedTransfer(id)
}
override suspend fun deleteTargetedTransfer(id: String): Result<Unit> = runCore { activeCore ->
activeCore.deleteTargetedTransfer(id)
}
override suspend fun shareSources(
sources: List<ShareSource>,
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)
}

View File

@@ -64,6 +64,14 @@ interface FileSystemService {
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share>
/** Builds platform share sources and creates a targeted transfer to a saved device. */
suspend fun createTargetedTransferFromPickedFiles(
repository: CoreGateway,
receiverEndpointId: String,
files: List<PickedShareFile>,
transferName: String?,
): Result<TargetedTransferModel>
}
@Composable

View File

@@ -0,0 +1,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
}

View File

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

View File

@@ -93,7 +93,9 @@ class ReceiveViewModel(
}
}
is CoreSignal.ApprovalChanged,
is CoreSignal.ReceiverHistoryChanged -> Unit
is CoreSignal.ReceiverHistoryChanged,
CoreSignal.PairingChanged,
CoreSignal.TargetedTransferChanged -> Unit
}
}
}

View File

@@ -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<PairingPromptState> = _state.asStateFlow()
/** Peers whose in-flow eligibility prompt was dismissed this session. */
private val dismissedEligibility = mutableSetOf<String>()
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) })
}
}
}

View File

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

View File

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

View File

@@ -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<PairingEligibilityModel> = emptyList(),
val pendingRelationships: List<DeviceRelationshipModel> = emptyList(),
val savedDevices: List<SavedDeviceModel> = emptyList(),
val busyPeerIds: Set<String> = emptySet(),
val labelingPeerId: String? = null,
val labelDraft: String = "",
val sendTargetPeerId: String? = null,
val isSending: Boolean = false,
)
sealed interface SavedDevicesEffect {
data object OpenFilePicker : SavedDevicesEffect
}
class SavedDevicesViewModel(
private val repository: CoreGateway,
private val fileSystemService: FileSystemService,
preferencesRepository: PreferencesRepository,
private val messages: UiMessageController,
) : ViewModel() {
private val _state = MutableStateFlow(SavedDevicesState())
val state: StateFlow<SavedDevicesState> = _state.asStateFlow()
private val effects = Channel<SavedDevicesEffect>(Channel.BUFFERED)
val effectFlow = effects.receiveAsFlow()
init {
viewModelScope.launch {
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<PickedShareFile>) {
val peerId = _state.value.sendTargetPeerId ?: return
if (files.isEmpty() || _state.value.isSending) return
viewModelScope.launch {
_state.update { it.copy(isSending = true) }
val transferName = when {
files.size == 1 -> files.first().displayName
files.all { it.isDirectory } -> "${files.size} folders"
else -> "${files.size} files"
}
val result = fileSystemService.createTargetedTransferFromPickedFiles(
repository = repository,
receiverEndpointId = peerId,
files = files,
transferName = transferName,
)
if (result.isSuccess) fileSystemService.discardPickedFiles(files)
_state.update { it.copy(isSending = false, sendTargetPeerId = null) }
result.fold(
onSuccess = {
messages.tryShow(
UiMessage(UiText.Resource(Res.string.saved_devices_send_started), UiMessageTone.Success),
)
},
onFailure = messages::error,
)
}
}
fun onFilePickFailed(reason: String) {
_state.update { it.copy(sendTargetPeerId = null) }
messages.error(IllegalStateException(reason.ifBlank { "selection failed" }))
}
private fun mutatePeer(peerEndpointId: String, block: suspend () -> Result<*>) {
if (peerEndpointId in _state.value.busyPeerIds) return
_state.update { it.copy(busyPeerIds = it.busyPeerIds + peerEndpointId) }
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),
)
}
}
}

View File

@@ -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<PendingTargetedOfferModel> = emptyList(),
val respondingIds: Set<String> = 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<TargetedOfferState> = _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,
)
}
}

View File

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

View File

@@ -94,6 +94,8 @@ class SendViewModel(
}
refreshReceiverStatuses(signal.transferId)
}
CoreSignal.PairingChanged,
CoreSignal.TargetedTransferChanged -> Unit
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<PairingPrompt.IncomingRequest>(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,
),
)
}

View File

@@ -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<Pair<String, String?>>("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<Pair<String, String?>>("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,
),
)
}

View File

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

View File

@@ -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<PairingEligibilityModel> = emptyList()
var deviceRelationships: List<DeviceRelationshipModel> = emptyList()
var savedDevices: List<SavedDeviceModel> = emptyList()
var blockedDevices: List<String> = emptyList()
var pendingTargetedOffers: List<PendingTargetedOfferModel> = emptyList()
var targetedTransfers: List<TargetedTransferModel> = emptyList()
var respondTargetedResult: Result<TargetedOfferResponseModel> =
Result.success(TargetedOfferResponseModel.Declined)
var createTargetedResult: Result<TargetedTransferModel> =
Result.failure(UnsupportedOperationException())
val forgottenDevices = mutableListOf<String>()
val blockedPeers = mutableListOf<String>()
val labeledDevices = mutableListOf<Pair<String, String?>>()
override suspend fun listPairingEligibilities() = Result.success(pairingEligibilities)
override suspend fun declinePairingEligibility(peerEndpointId: String): Result<Unit> {
pairingEligibilities = pairingEligibilities.filterNot { it.peerEndpointId == peerEndpointId }
return Result.success(Unit)
}
val requestedPairings = mutableListOf<String>()
val pairingResponses = mutableListOf<Pair<String, Boolean>>()
var requestPairingResult: Result<Boolean> = Result.success(true)
var respondPairingResult: Result<Boolean> = Result.success(true)
val createdTargetedTransfers = mutableListOf<Triple<String, List<uniffi.vnidrop.ShareSource>, String?>>()
val receivedTargetedTransferIds = mutableListOf<String>()
val respondedTargetedOffers = mutableListOf<Pair<String, Boolean>>()
override suspend fun requestSavedDevicePairing(peerEndpointId: String): Result<Boolean> {
requestedPairings += peerEndpointId
return requestPairingResult
}
override suspend fun respondToDevicePairing(peerEndpointId: String, accepted: Boolean): Result<Boolean> {
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<Unit> {
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<Unit> {
forgottenDevices += peerEndpointId
savedDevices = savedDevices.filterNot { it.endpointId == peerEndpointId }
return Result.success(Unit)
}
override suspend fun blockDevice(peerEndpointId: String): Result<Unit> {
blockedPeers += peerEndpointId
blockedDevices = (blockedDevices + peerEndpointId).distinct()
return Result.success(Unit)
}
override suspend fun unblockDevice(peerEndpointId: String): Result<Unit> {
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<TargetedOfferResponseModel> {
respondedTargetedOffers += transferId to accepted
return respondTargetedResult
}
override suspend fun createTargetedTransfer(
receiverEndpointId: String,
sources: List<uniffi.vnidrop.ShareSource>,
transferName: String?,
): Result<TargetedTransferModel> {
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<Unit> {
receivedTargetedTransferIds += transferId
return receiveResult
}
override suspend fun receiveTargetedTransferWithOutputSink(
transferId: String,
outputSink: ReceiveOutputSink,
): Result<Unit> {
receivedTargetedTransferIds += transferId
return receiveResult
}
override suspend fun receiveTargetedTransferWithOutputSinkV2(
transferId: String,
outputSink: ReceiveOutputSinkV2,
): Result<Unit> {
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<PickedShareFile>,
transferName: String?,
): Result<TargetedTransferModel> {
val sources = files.map { file ->
uniffi.vnidrop.ShareSource(
kind = uniffi.vnidrop.SourceKind.PATH,
value = file.value,
displayName = file.displayName,
isDirectory = false,
)
}
return repository.createTargetedTransfer(receiverEndpointId, sources, transferName)
}
}
class FakeFilePreviewRepository : FilePreviewRepository {

View File

@@ -61,15 +61,26 @@ private class JvmFileSystemService : FileSystemService {
accessPolicy: ShareAccessPolicy,
): Result<Share> {
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<PickedShareFile>,
transferName: String?,
): Result<TargetedTransferModel> {
require(files.isNotEmpty()) { "Select at least one file to share" }
return repository.createTargetedTransfer(receiverEndpointId, pathShareSources(files), transferName)
}
private fun pathShareSources(files: List<PickedShareFile>) = files.map { file ->
uniffi.vnidrop.ShareSource(
kind = uniffi.vnidrop.SourceKind.PATH,
value = file.value,
displayName = file.displayName,
isDirectory = file.isDirectory || File(file.value).isDirectory,
)
}
}

View File

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