mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-12 05:29:57 +02:00
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:
@@ -1458,6 +1458,57 @@
|
||||
"ru": "Не удалось обработать данные передачи. Попросите отправителя поделиться ими снова."
|
||||
}
|
||||
},
|
||||
"experimental_saved_devices_description": {
|
||||
"context": "Settings > Experimental: toggle description for saved devices and targeted transfers.",
|
||||
"targets": [
|
||||
"kmp"
|
||||
],
|
||||
"translations": {
|
||||
"en": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
|
||||
"fr": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
|
||||
"es": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
|
||||
"it": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
|
||||
"de": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
|
||||
"pt": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
|
||||
"pl": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
|
||||
"nl": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
|
||||
"ru": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change."
|
||||
}
|
||||
},
|
||||
"experimental_saved_devices_title": {
|
||||
"context": "Settings > Experimental: toggle title for saved devices feature.",
|
||||
"targets": [
|
||||
"kmp"
|
||||
],
|
||||
"translations": {
|
||||
"en": "Saved devices",
|
||||
"fr": "Saved devices",
|
||||
"es": "Saved devices",
|
||||
"it": "Saved devices",
|
||||
"de": "Saved devices",
|
||||
"pt": "Saved devices",
|
||||
"pl": "Saved devices",
|
||||
"nl": "Saved devices",
|
||||
"ru": "Saved devices"
|
||||
}
|
||||
},
|
||||
"experimental_settings_title": {
|
||||
"context": "Settings: experimental section title (Android).",
|
||||
"targets": [
|
||||
"kmp"
|
||||
],
|
||||
"translations": {
|
||||
"en": "Experimental",
|
||||
"fr": "Experimental",
|
||||
"es": "Experimental",
|
||||
"it": "Experimental",
|
||||
"de": "Experimental",
|
||||
"pt": "Experimental",
|
||||
"pl": "Experimental",
|
||||
"nl": "Experimental",
|
||||
"ru": "Experimental"
|
||||
}
|
||||
},
|
||||
"field_receiver_name": {
|
||||
"context": "Text field label: the receiver's display name.",
|
||||
"translations": {
|
||||
@@ -4787,6 +4838,380 @@
|
||||
"nl": "Weigeren",
|
||||
"ru": "Отклонить"
|
||||
}
|
||||
},
|
||||
"saved_devices_empty": {
|
||||
"context": "Experimental saved devices: empty list when none are saved yet.",
|
||||
"translations": {
|
||||
"en": "No saved devices yet. Finish a transfer, then remember a device.",
|
||||
"fr": "No saved devices yet. Finish a transfer, then remember a device.",
|
||||
"es": "No saved devices yet. Finish a transfer, then remember a device.",
|
||||
"it": "No saved devices yet. Finish a transfer, then remember a device.",
|
||||
"de": "No saved devices yet. Finish a transfer, then remember a device.",
|
||||
"pt": "No saved devices yet. Finish a transfer, then remember a device.",
|
||||
"pl": "No saved devices yet. Finish a transfer, then remember a device.",
|
||||
"nl": "No saved devices yet. Finish a transfer, then remember a device.",
|
||||
"ru": "No saved devices yet. Finish a transfer, then remember a device."
|
||||
},
|
||||
"targets": [
|
||||
"kmp"
|
||||
]
|
||||
},
|
||||
"saved_devices_eligibility_title": {
|
||||
"context": "Experimental saved devices: section for peers eligible to pair after a transfer.",
|
||||
"translations": {
|
||||
"en": "Ready to remember",
|
||||
"fr": "Ready to remember",
|
||||
"es": "Ready to remember",
|
||||
"it": "Ready to remember",
|
||||
"de": "Ready to remember",
|
||||
"pt": "Ready to remember",
|
||||
"pl": "Ready to remember",
|
||||
"nl": "Ready to remember",
|
||||
"ru": "Ready to remember"
|
||||
},
|
||||
"targets": [
|
||||
"kmp"
|
||||
]
|
||||
},
|
||||
"saved_devices_pending_title": {
|
||||
"context": "Experimental saved devices: section for in-progress pairing.",
|
||||
"translations": {
|
||||
"en": "Pending pairing",
|
||||
"fr": "Pending pairing",
|
||||
"es": "Pending pairing",
|
||||
"it": "Pending pairing",
|
||||
"de": "Pending pairing",
|
||||
"pt": "Pending pairing",
|
||||
"pl": "Pending pairing",
|
||||
"nl": "Pending pairing",
|
||||
"ru": "Pending pairing"
|
||||
},
|
||||
"targets": [
|
||||
"kmp"
|
||||
]
|
||||
},
|
||||
"saved_devices_list_title": {
|
||||
"context": "Experimental saved devices: section listing saved devices.",
|
||||
"translations": {
|
||||
"en": "Saved devices",
|
||||
"fr": "Saved devices",
|
||||
"es": "Saved devices",
|
||||
"it": "Saved devices",
|
||||
"de": "Saved devices",
|
||||
"pt": "Saved devices",
|
||||
"pl": "Saved devices",
|
||||
"nl": "Saved devices",
|
||||
"ru": "Saved devices"
|
||||
},
|
||||
"targets": [
|
||||
"kmp"
|
||||
]
|
||||
},
|
||||
"saved_devices_unnamed": {
|
||||
"context": "Fallback display when a saved device has no local label or remote name.",
|
||||
"translations": {
|
||||
"en": "Saved device",
|
||||
"fr": "Saved device",
|
||||
"es": "Saved device",
|
||||
"it": "Saved device",
|
||||
"de": "Saved device",
|
||||
"pt": "Saved device",
|
||||
"pl": "Saved device",
|
||||
"nl": "Saved device",
|
||||
"ru": "Saved device"
|
||||
},
|
||||
"targets": [
|
||||
"kmp"
|
||||
]
|
||||
},
|
||||
"saved_devices_remember_action": {
|
||||
"context": "Action that starts mutual-consent pairing for an eligible peer.",
|
||||
"translations": {
|
||||
"en": "Remember",
|
||||
"fr": "Remember",
|
||||
"es": "Remember",
|
||||
"it": "Remember",
|
||||
"de": "Remember",
|
||||
"pt": "Remember",
|
||||
"pl": "Remember",
|
||||
"nl": "Remember",
|
||||
"ru": "Remember"
|
||||
},
|
||||
"targets": [
|
||||
"kmp"
|
||||
]
|
||||
},
|
||||
"saved_devices_decline_action": {
|
||||
"context": "Action that declines pairing eligibility for a peer.",
|
||||
"translations": {
|
||||
"en": "Decline",
|
||||
"fr": "Decline",
|
||||
"es": "Decline",
|
||||
"it": "Decline",
|
||||
"de": "Decline",
|
||||
"pt": "Decline",
|
||||
"pl": "Decline",
|
||||
"nl": "Decline",
|
||||
"ru": "Decline"
|
||||
},
|
||||
"targets": [
|
||||
"kmp"
|
||||
]
|
||||
},
|
||||
"saved_devices_accept_pairing_action": {
|
||||
"context": "Action that accepts an incoming pairing request.",
|
||||
"translations": {
|
||||
"en": "Accept",
|
||||
"fr": "Accept",
|
||||
"es": "Accept",
|
||||
"it": "Accept",
|
||||
"de": "Accept",
|
||||
"pt": "Accept",
|
||||
"pl": "Accept",
|
||||
"nl": "Accept",
|
||||
"ru": "Accept"
|
||||
},
|
||||
"targets": [
|
||||
"kmp"
|
||||
]
|
||||
},
|
||||
"saved_devices_send_action": {
|
||||
"context": "Action that starts a targeted send to a saved device.",
|
||||
"translations": {
|
||||
"en": "Send files",
|
||||
"fr": "Send files",
|
||||
"es": "Send files",
|
||||
"it": "Send files",
|
||||
"de": "Send files",
|
||||
"pt": "Send files",
|
||||
"pl": "Send files",
|
||||
"nl": "Send files",
|
||||
"ru": "Send files"
|
||||
},
|
||||
"targets": [
|
||||
"kmp"
|
||||
]
|
||||
},
|
||||
"saved_devices_label_action": {
|
||||
"context": "Action that edits the local label for a saved device.",
|
||||
"translations": {
|
||||
"en": "Label",
|
||||
"fr": "Label",
|
||||
"es": "Label",
|
||||
"it": "Label",
|
||||
"de": "Label",
|
||||
"pt": "Label",
|
||||
"pl": "Label",
|
||||
"nl": "Label",
|
||||
"ru": "Label"
|
||||
},
|
||||
"targets": [
|
||||
"kmp"
|
||||
]
|
||||
},
|
||||
"saved_devices_forget_action": {
|
||||
"context": "Action that forgets a saved device relationship.",
|
||||
"translations": {
|
||||
"en": "Forget",
|
||||
"fr": "Forget",
|
||||
"es": "Forget",
|
||||
"it": "Forget",
|
||||
"de": "Forget",
|
||||
"pt": "Forget",
|
||||
"pl": "Forget",
|
||||
"nl": "Forget",
|
||||
"ru": "Forget"
|
||||
},
|
||||
"targets": [
|
||||
"kmp"
|
||||
]
|
||||
},
|
||||
"saved_devices_block_action": {
|
||||
"context": "Action that blocks a device identity.",
|
||||
"translations": {
|
||||
"en": "Block",
|
||||
"fr": "Block",
|
||||
"es": "Block",
|
||||
"it": "Block",
|
||||
"de": "Block",
|
||||
"pt": "Block",
|
||||
"pl": "Block",
|
||||
"nl": "Block",
|
||||
"ru": "Block"
|
||||
},
|
||||
"targets": [
|
||||
"kmp"
|
||||
]
|
||||
},
|
||||
"saved_devices_label_title": {
|
||||
"context": "Dialog title for editing a saved device local label.",
|
||||
"translations": {
|
||||
"en": "Device label",
|
||||
"fr": "Device label",
|
||||
"es": "Device label",
|
||||
"it": "Device label",
|
||||
"de": "Device label",
|
||||
"pt": "Device label",
|
||||
"pl": "Device label",
|
||||
"nl": "Device label",
|
||||
"ru": "Device label"
|
||||
},
|
||||
"targets": [
|
||||
"kmp"
|
||||
]
|
||||
},
|
||||
"saved_devices_label_placeholder": {
|
||||
"context": "Placeholder for the local label text field.",
|
||||
"translations": {
|
||||
"en": "Label",
|
||||
"fr": "Label",
|
||||
"es": "Label",
|
||||
"it": "Label",
|
||||
"de": "Label",
|
||||
"pt": "Label",
|
||||
"pl": "Label",
|
||||
"nl": "Label",
|
||||
"ru": "Label"
|
||||
},
|
||||
"targets": [
|
||||
"kmp"
|
||||
]
|
||||
},
|
||||
"saved_devices_label_save": {
|
||||
"context": "Button that saves the local device label.",
|
||||
"translations": {
|
||||
"en": "Save",
|
||||
"fr": "Save",
|
||||
"es": "Save",
|
||||
"it": "Save",
|
||||
"de": "Save",
|
||||
"pt": "Save",
|
||||
"pl": "Save",
|
||||
"nl": "Save",
|
||||
"ru": "Save"
|
||||
},
|
||||
"targets": [
|
||||
"kmp"
|
||||
]
|
||||
},
|
||||
"saved_devices_label_clear": {
|
||||
"context": "Button that clears the local device label.",
|
||||
"translations": {
|
||||
"en": "Clear label",
|
||||
"fr": "Clear label",
|
||||
"es": "Clear label",
|
||||
"it": "Clear label",
|
||||
"de": "Clear label",
|
||||
"pt": "Clear label",
|
||||
"pl": "Clear label",
|
||||
"nl": "Clear label",
|
||||
"ru": "Clear label"
|
||||
},
|
||||
"targets": [
|
||||
"kmp"
|
||||
]
|
||||
},
|
||||
"saved_devices_pending_outgoing": {
|
||||
"context": "Status text while waiting for the peer to accept pairing.",
|
||||
"translations": {
|
||||
"en": "Waiting for the other device",
|
||||
"fr": "Waiting for the other device",
|
||||
"es": "Waiting for the other device",
|
||||
"it": "Waiting for the other device",
|
||||
"de": "Waiting for the other device",
|
||||
"pt": "Waiting for the other device",
|
||||
"pl": "Waiting for the other device",
|
||||
"nl": "Waiting for the other device",
|
||||
"ru": "Waiting for the other device"
|
||||
},
|
||||
"targets": [
|
||||
"kmp"
|
||||
]
|
||||
},
|
||||
"saved_devices_pending_incoming": {
|
||||
"context": "Status text when this device must accept a pairing request.",
|
||||
"translations": {
|
||||
"en": "Wants to remember this device",
|
||||
"fr": "Wants to remember this device",
|
||||
"es": "Wants to remember this device",
|
||||
"it": "Wants to remember this device",
|
||||
"de": "Wants to remember this device",
|
||||
"pt": "Wants to remember this device",
|
||||
"pl": "Wants to remember this device",
|
||||
"nl": "Wants to remember this device",
|
||||
"ru": "Wants to remember this device"
|
||||
},
|
||||
"targets": [
|
||||
"kmp"
|
||||
]
|
||||
},
|
||||
"saved_devices_send_started": {
|
||||
"context": "Snackbar after creating a targeted transfer offer.",
|
||||
"translations": {
|
||||
"en": "Transfer offer sent",
|
||||
"fr": "Transfer offer sent",
|
||||
"es": "Transfer offer sent",
|
||||
"it": "Transfer offer sent",
|
||||
"de": "Transfer offer sent",
|
||||
"pt": "Transfer offer sent",
|
||||
"pl": "Transfer offer sent",
|
||||
"nl": "Transfer offer sent",
|
||||
"ru": "Transfer offer sent"
|
||||
},
|
||||
"targets": [
|
||||
"kmp"
|
||||
]
|
||||
},
|
||||
"saved_devices_forgotten": {
|
||||
"context": "Snackbar after forgetting a saved device.",
|
||||
"translations": {
|
||||
"en": "Saved device forgotten",
|
||||
"fr": "Saved device forgotten",
|
||||
"es": "Saved device forgotten",
|
||||
"it": "Saved device forgotten",
|
||||
"de": "Saved device forgotten",
|
||||
"pt": "Saved device forgotten",
|
||||
"pl": "Saved device forgotten",
|
||||
"nl": "Saved device forgotten",
|
||||
"ru": "Saved device forgotten"
|
||||
},
|
||||
"targets": [
|
||||
"kmp"
|
||||
]
|
||||
},
|
||||
"saved_devices_blocked": {
|
||||
"context": "Snackbar after blocking a device.",
|
||||
"translations": {
|
||||
"en": "Device blocked",
|
||||
"fr": "Device blocked",
|
||||
"es": "Device blocked",
|
||||
"it": "Device blocked",
|
||||
"de": "Device blocked",
|
||||
"pt": "Device blocked",
|
||||
"pl": "Device blocked",
|
||||
"nl": "Device blocked",
|
||||
"ru": "Device blocked"
|
||||
},
|
||||
"targets": [
|
||||
"kmp"
|
||||
]
|
||||
},
|
||||
"saved_devices_labeled": {
|
||||
"context": "Snackbar after updating a saved device label.",
|
||||
"translations": {
|
||||
"en": "Label updated",
|
||||
"fr": "Label updated",
|
||||
"es": "Label updated",
|
||||
"it": "Label updated",
|
||||
"de": "Label updated",
|
||||
"pt": "Label updated",
|
||||
"pl": "Label updated",
|
||||
"nl": "Label updated",
|
||||
"ru": "Label updated"
|
||||
},
|
||||
"targets": [
|
||||
"kmp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
44
shared/docs/saved-devices-ui-contract.md
Normal file
44
shared/docs/saved-devices-ui-contract.md
Normal 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 contract’s MVP chrome
|
||||
|
||||
Resume / cancel / delete / grant-rotate UI, background notifications, and mid-transfer progress polish may follow without changing the approve/pull rules above.
|
||||
|
||||
Block is available from the Saved devices area. Unblock is not required for the KMP MVP chrome when the product surface does not already expose blocked-device management.
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -100,6 +100,9 @@
|
||||
<string name="error_starting_up">VniDrop démarre encore. Rouvrez l’invitation dans un instant.</string>
|
||||
<string name="error_storage_full">L’espace de stockage est insuffisant pour enregistrer ce transfert. Libérez de l’espace et réessayez.</string>
|
||||
<string name="error_transfer">Les données du transfert n’ont pas pu être traitées. Demandez à l’expéditeur de les partager à nouveau.</string>
|
||||
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
|
||||
<string name="experimental_saved_devices_title">Saved devices</string>
|
||||
<string name="experimental_settings_title">Experimental</string>
|
||||
<string name="field_receiver_name">Nom du destinataire</string>
|
||||
<string name="field_sender_name">Nom de l’expéditeur</string>
|
||||
<string name="field_transfer_name">Nom du transfert</string>
|
||||
@@ -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>
|
||||
|
||||
@@ -100,6 +100,9 @@
|
||||
<string name="error_starting_up">VniDrop è ancora in fase di avvio. Riapra l’invito tra un momento.</string>
|
||||
<string name="error_storage_full">Lo spazio di archiviazione non è sufficiente per salvare il trasferimento. Liberi spazio e riprovi.</string>
|
||||
<string name="error_transfer">Non è stato possibile elaborare i dati del trasferimento. Chieda al mittente di condividerli di nuovo.</string>
|
||||
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
|
||||
<string name="experimental_saved_devices_title">Saved devices</string>
|
||||
<string name="experimental_settings_title">Experimental</string>
|
||||
<string name="field_receiver_name">Nome del destinatario</string>
|
||||
<string name="field_sender_name">Nome del mittente</string>
|
||||
<string name="field_transfer_name">Nome del trasferimento</string>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,9 @@ class ReceiveViewModel(
|
||||
}
|
||||
}
|
||||
is CoreSignal.ApprovalChanged,
|
||||
is CoreSignal.ReceiverHistoryChanged -> Unit
|
||||
is CoreSignal.ReceiverHistoryChanged,
|
||||
CoreSignal.PairingChanged,
|
||||
CoreSignal.TargetedTransferChanged -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) + "…"
|
||||
@@ -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) + "…"
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) + "…"
|
||||
@@ -94,6 +94,8 @@ class SendViewModel(
|
||||
}
|
||||
refreshReceiverStatuses(signal.transferId)
|
||||
}
|
||||
CoreSignal.PairingChanged,
|
||||
CoreSignal.TargetedTransferChanged -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user