mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
feat(shared): align non-Apple UX with Apple
This commit is contained in:
@@ -828,6 +828,20 @@
|
||||
"ru": "Назад"
|
||||
}
|
||||
},
|
||||
"button_more_actions": {
|
||||
"context": "Accessibility label for a button that opens more actions for an item.",
|
||||
"translations": {
|
||||
"en": "More actions",
|
||||
"fr": "Plus d’actions",
|
||||
"es": "Más acciones",
|
||||
"it": "Altre azioni",
|
||||
"de": "Weitere Aktionen",
|
||||
"pt": "Mais ações",
|
||||
"pl": "Więcej działań",
|
||||
"nl": "Meer acties",
|
||||
"ru": "Другие действия"
|
||||
}
|
||||
},
|
||||
"button_cancel": {
|
||||
"context": "Button: cancel the current action or dialog.",
|
||||
"translations": {
|
||||
@@ -1826,9 +1840,6 @@
|
||||
},
|
||||
"notifications_receive_completed_body": {
|
||||
"context": "Notification body shown when an incoming transfer finishes downloading. {transferName} = transfer name.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"args": [
|
||||
{
|
||||
"name": "transferName",
|
||||
@@ -1849,9 +1860,6 @@
|
||||
},
|
||||
"notifications_receive_completed_title": {
|
||||
"context": "Notification title shown when an incoming transfer finishes downloading.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"translations": {
|
||||
"en": "Download complete",
|
||||
"fr": "Téléchargement terminé",
|
||||
@@ -1866,9 +1874,6 @@
|
||||
},
|
||||
"notifications_receive_failed_body": {
|
||||
"context": "Notification body shown when an incoming transfer fails. {transferName} = transfer name.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"args": [
|
||||
{
|
||||
"name": "transferName",
|
||||
@@ -1889,9 +1894,6 @@
|
||||
},
|
||||
"notifications_receive_failed_title": {
|
||||
"context": "Notification title shown when an incoming transfer fails.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"translations": {
|
||||
"en": "Download failed",
|
||||
"fr": "Échec du téléchargement",
|
||||
@@ -1906,9 +1908,6 @@
|
||||
},
|
||||
"notifications_receiver_completed_body": {
|
||||
"context": "Notification body shown to the sender when a receiver finishes downloading a shared transfer. {receiver} = receiver name, {transferName} = transfer name.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"args": [
|
||||
{
|
||||
"name": "receiver",
|
||||
@@ -1933,9 +1932,6 @@
|
||||
},
|
||||
"notifications_receiver_completed_title": {
|
||||
"context": "Notification title shown to the sender when a receiver finishes downloading a shared transfer.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"translations": {
|
||||
"en": "Transfer received",
|
||||
"fr": "Transfert reçu",
|
||||
@@ -1950,9 +1946,6 @@
|
||||
},
|
||||
"notifications_receiver_failed_body": {
|
||||
"context": "Notification body shown to the sender when a receiver's download fails. {receiver} = receiver name, {transferName} = transfer name.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"args": [
|
||||
{
|
||||
"name": "receiver",
|
||||
@@ -1977,9 +1970,6 @@
|
||||
},
|
||||
"notifications_receiver_failed_title": {
|
||||
"context": "Notification title shown to the sender when a receiver's download fails.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"translations": {
|
||||
"en": "Delivery failed",
|
||||
"fr": "Échec de l'envoi",
|
||||
@@ -1994,9 +1984,6 @@
|
||||
},
|
||||
"notifications_send_failed_body": {
|
||||
"context": "Notification body shown to the sender when a shared transfer fails. {transferName} = transfer name.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"args": [
|
||||
{
|
||||
"name": "transferName",
|
||||
@@ -2017,9 +2004,6 @@
|
||||
},
|
||||
"notifications_send_failed_title": {
|
||||
"context": "Notification title shown to the sender when a shared transfer fails.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"translations": {
|
||||
"en": "Sharing failed",
|
||||
"fr": "Échec du partage",
|
||||
|
||||
@@ -105,6 +105,30 @@ private class AndroidFileSystemService(
|
||||
|
||||
override suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong = directorySize(context.cacheDir)
|
||||
|
||||
override suspend fun reclaimTemporaryStorage(appDataDir: String, receiveFolder: ReceiveFolder): ULong {
|
||||
var reclaimed = 0UL
|
||||
context.cacheDir.listFiles().orEmpty().forEach { entry ->
|
||||
val size = if (entry.isDirectory) directorySize(entry) else entry.length().coerceAtLeast(0L).toULong()
|
||||
if (entry.deleteRecursively()) reclaimed += size
|
||||
}
|
||||
val appDataRoot = File(appDataDir)
|
||||
val appDataIsOwned = runCatching {
|
||||
val appDataPath = appDataRoot.canonicalPath
|
||||
val filesPath = context.filesDir.canonicalPath
|
||||
appDataPath == filesPath || appDataPath.startsWith(filesPath + File.separator)
|
||||
}.getOrDefault(false)
|
||||
if (appDataIsOwned) {
|
||||
appDataRoot.walkTopDown()
|
||||
.filter { it.isDirectory && it.name == ".Trash" }
|
||||
.toList()
|
||||
.forEach { trash ->
|
||||
val size = directorySize(trash)
|
||||
if (trash.deleteRecursively()) reclaimed += size
|
||||
}
|
||||
}
|
||||
return reclaimed
|
||||
}
|
||||
|
||||
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? =
|
||||
when (folder.kind) {
|
||||
ReceiveFolderKind.AndroidPublicDownloads -> AndroidMediaStoreDownloadsSink(context)
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<string name="bug_report_what_label">Was ist passiert?</string>
|
||||
<string name="button_approve">Genehmigen</string>
|
||||
<string name="button_back">Zurück</string>
|
||||
<string name="button_more_actions">Weitere Aktionen</string>
|
||||
<string name="button_cancel">Abbrechen</string>
|
||||
<string name="button_cancel_receive">Abbrechen</string>
|
||||
<string name="button_change_files">Dateien ändern</string>
|
||||
@@ -122,6 +123,16 @@
|
||||
<string name="notifications_enabled_message">Mitteilungen aktiviert.</string>
|
||||
<string name="notifications_local_title">Mitteilungen erlauben</string>
|
||||
<string name="notifications_permission_denied">Mitteilungen sind für VniDrop deaktiviert. Sie können sie in den Einstellungen aktivieren.</string>
|
||||
<string name="notifications_receive_completed_body">„%1$s“ wurde vollständig heruntergeladen.</string>
|
||||
<string name="notifications_receive_completed_title">Download abgeschlossen</string>
|
||||
<string name="notifications_receive_failed_body">„%1$s“ konnte nicht empfangen werden.</string>
|
||||
<string name="notifications_receive_failed_title">Download fehlgeschlagen</string>
|
||||
<string name="notifications_receiver_completed_body">%1$s hat „%2$s“ vollständig empfangen.</string>
|
||||
<string name="notifications_receiver_completed_title">Übertragung empfangen</string>
|
||||
<string name="notifications_receiver_failed_body">%1$s konnte „%2$s“ nicht empfangen</string>
|
||||
<string name="notifications_receiver_failed_title">Übertragung fehlgeschlagen</string>
|
||||
<string name="notifications_send_failed_body">„%1$s“ konnte nicht geteilt werden.</string>
|
||||
<string name="notifications_send_failed_title">Freigabe fehlgeschlagen</string>
|
||||
<string name="notifications_settings_open_failed">Die Mitteilungseinstellungen konnten nicht geöffnet werden.</string>
|
||||
<string name="notifications_title">Mitteilungen</string>
|
||||
<string name="notifications_unsupported">Mitteilungen sind auf diesem Gerät nicht verfügbar.</string>
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<string name="bug_report_what_label">¿Qué ocurrió?</string>
|
||||
<string name="button_approve">Aprobar</string>
|
||||
<string name="button_back">Atrás</string>
|
||||
<string name="button_more_actions">Más acciones</string>
|
||||
<string name="button_cancel">Cancelar</string>
|
||||
<string name="button_cancel_receive">Cancelar</string>
|
||||
<string name="button_change_files">Cambiar archivos</string>
|
||||
@@ -122,6 +123,16 @@
|
||||
<string name="notifications_enabled_message">Notificaciones activadas.</string>
|
||||
<string name="notifications_local_title">Permitir notificaciones</string>
|
||||
<string name="notifications_permission_denied">Las notificaciones están desactivadas para VniDrop. Puede activarlas en Ajustes.</string>
|
||||
<string name="notifications_receive_completed_body">«%1$s» terminó de descargarse.</string>
|
||||
<string name="notifications_receive_completed_title">Descarga completada</string>
|
||||
<string name="notifications_receive_failed_body">No se pudo recibir «%1$s».</string>
|
||||
<string name="notifications_receive_failed_title">Error en la descarga</string>
|
||||
<string name="notifications_receiver_completed_body">%1$s terminó de recibir «%2$s».</string>
|
||||
<string name="notifications_receiver_completed_title">Transferencia recibida</string>
|
||||
<string name="notifications_receiver_failed_body">%1$s no pudo recibir «%2$s»</string>
|
||||
<string name="notifications_receiver_failed_title">Error en la entrega</string>
|
||||
<string name="notifications_send_failed_body">No se pudo compartir «%1$s».</string>
|
||||
<string name="notifications_send_failed_title">Error al compartir</string>
|
||||
<string name="notifications_settings_open_failed">No se pudieron abrir los ajustes de notificaciones.</string>
|
||||
<string name="notifications_title">Notificaciones</string>
|
||||
<string name="notifications_unsupported">Las notificaciones no están disponibles en este dispositivo.</string>
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<string name="bug_report_what_label">Que s’est-il passé ?</string>
|
||||
<string name="button_approve">Approuver</string>
|
||||
<string name="button_back">Retour</string>
|
||||
<string name="button_more_actions">Plus d’actions</string>
|
||||
<string name="button_cancel">Annuler</string>
|
||||
<string name="button_cancel_receive">Annuler</string>
|
||||
<string name="button_change_files">Modifier les fichiers</string>
|
||||
@@ -122,6 +123,16 @@
|
||||
<string name="notifications_enabled_message">Notifications activées.</string>
|
||||
<string name="notifications_local_title">Autoriser les notifications</string>
|
||||
<string name="notifications_permission_denied">Les notifications sont désactivées pour VniDrop. Vous pouvez les activer dans les Réglages.</string>
|
||||
<string name="notifications_receive_completed_body">« %1$s » a fini de se télécharger.</string>
|
||||
<string name="notifications_receive_completed_title">Téléchargement terminé</string>
|
||||
<string name="notifications_receive_failed_body">« %1$s » n’a pas pu être reçu.</string>
|
||||
<string name="notifications_receive_failed_title">Échec du téléchargement</string>
|
||||
<string name="notifications_receiver_completed_body">%1$s a fini de recevoir « %2$s ».</string>
|
||||
<string name="notifications_receiver_completed_title">Transfert reçu</string>
|
||||
<string name="notifications_receiver_failed_body">%1$s n\'a pas pu recevoir « %2$s »</string>
|
||||
<string name="notifications_receiver_failed_title">Échec de l\'envoi</string>
|
||||
<string name="notifications_send_failed_body">« %1$s » n’a pas pu être partagé.</string>
|
||||
<string name="notifications_send_failed_title">Échec du partage</string>
|
||||
<string name="notifications_settings_open_failed">Impossible d’ouvrir les réglages de notifications.</string>
|
||||
<string name="notifications_title">Notifications</string>
|
||||
<string name="notifications_unsupported">Les notifications ne sont pas disponibles sur cet appareil.</string>
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<string name="bug_report_what_label">Cosa è accaduto?</string>
|
||||
<string name="button_approve">Approva</string>
|
||||
<string name="button_back">Indietro</string>
|
||||
<string name="button_more_actions">Altre azioni</string>
|
||||
<string name="button_cancel">Annulla</string>
|
||||
<string name="button_cancel_receive">Annulla</string>
|
||||
<string name="button_change_files">Cambia file</string>
|
||||
@@ -122,6 +123,16 @@
|
||||
<string name="notifications_enabled_message">Notifiche attivate.</string>
|
||||
<string name="notifications_local_title">Consenti le notifiche</string>
|
||||
<string name="notifications_permission_denied">Le notifiche sono disattivate per VniDrop. Può attivarle in Impostazioni.</string>
|
||||
<string name="notifications_receive_completed_body">«%1$s» è stato scaricato.</string>
|
||||
<string name="notifications_receive_completed_title">Download completato</string>
|
||||
<string name="notifications_receive_failed_body">Impossibile ricevere «%1$s».</string>
|
||||
<string name="notifications_receive_failed_title">Download non riuscito</string>
|
||||
<string name="notifications_receiver_completed_body">%1$s ha finito di ricevere «%2$s».</string>
|
||||
<string name="notifications_receiver_completed_title">Trasferimento ricevuto</string>
|
||||
<string name="notifications_receiver_failed_body">%1$s non ha potuto ricevere “%2$s”</string>
|
||||
<string name="notifications_receiver_failed_title">Consegna non riuscita</string>
|
||||
<string name="notifications_send_failed_body">Impossibile condividere «%1$s».</string>
|
||||
<string name="notifications_send_failed_title">Condivisione non riuscita</string>
|
||||
<string name="notifications_settings_open_failed">Impossibile aprire le impostazioni delle notifiche.</string>
|
||||
<string name="notifications_title">Notifiche</string>
|
||||
<string name="notifications_unsupported">Le notifiche non sono disponibili su questo dispositivo.</string>
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<string name="bug_report_what_label">Wat is er gebeurd?</string>
|
||||
<string name="button_approve">Goedkeuren</string>
|
||||
<string name="button_back">Terug</string>
|
||||
<string name="button_more_actions">Meer acties</string>
|
||||
<string name="button_cancel">Annuleren</string>
|
||||
<string name="button_cancel_receive">Annuleren</string>
|
||||
<string name="button_change_files">Bestanden wijzigen</string>
|
||||
@@ -122,6 +123,16 @@
|
||||
<string name="notifications_enabled_message">Meldingen ingeschakeld.</string>
|
||||
<string name="notifications_local_title">Meldingen toestaan</string>
|
||||
<string name="notifications_permission_denied">Meldingen zijn uitgeschakeld voor VniDrop. U kunt ze inschakelen in Instellingen.</string>
|
||||
<string name="notifications_receive_completed_body">‘%1$s’ is volledig gedownload.</string>
|
||||
<string name="notifications_receive_completed_title">Download voltooid</string>
|
||||
<string name="notifications_receive_failed_body">‘%1$s’ kon niet worden ontvangen.</string>
|
||||
<string name="notifications_receive_failed_title">Download mislukt</string>
|
||||
<string name="notifications_receiver_completed_body">%1$s heeft ‘%2$s’ volledig ontvangen.</string>
|
||||
<string name="notifications_receiver_completed_title">Overdracht ontvangen</string>
|
||||
<string name="notifications_receiver_failed_body">%1$s kon “%2$s” niet ontvangen</string>
|
||||
<string name="notifications_receiver_failed_title">Levering mislukt</string>
|
||||
<string name="notifications_send_failed_body">‘%1$s’ kon niet worden gedeeld.</string>
|
||||
<string name="notifications_send_failed_title">Delen mislukt</string>
|
||||
<string name="notifications_settings_open_failed">De meldingsinstellingen konden niet worden geopend.</string>
|
||||
<string name="notifications_title">Meldingen</string>
|
||||
<string name="notifications_unsupported">Meldingen zijn niet beschikbaar op dit apparaat.</string>
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<string name="bug_report_what_label">Co się stało?</string>
|
||||
<string name="button_approve">Zatwierdź</string>
|
||||
<string name="button_back">Wstecz</string>
|
||||
<string name="button_more_actions">Więcej działań</string>
|
||||
<string name="button_cancel">Anuluj</string>
|
||||
<string name="button_cancel_receive">Anuluj</string>
|
||||
<string name="button_change_files">Zmień pliki</string>
|
||||
@@ -122,6 +123,16 @@
|
||||
<string name="notifications_enabled_message">Powiadomienia włączone.</string>
|
||||
<string name="notifications_local_title">Zezwól na powiadomienia</string>
|
||||
<string name="notifications_permission_denied">Powiadomienia są wyłączone dla VniDrop. Możesz je włączyć w Ustawieniach.</string>
|
||||
<string name="notifications_receive_completed_body">Zakończono pobieranie „%1$s”.</string>
|
||||
<string name="notifications_receive_completed_title">Pobieranie zakończone</string>
|
||||
<string name="notifications_receive_failed_body">Nie udało się odebrać „%1$s”.</string>
|
||||
<string name="notifications_receive_failed_title">Pobieranie nie powiodło się</string>
|
||||
<string name="notifications_receiver_completed_body">%1$s zakończył odbieranie „%2$s”.</string>
|
||||
<string name="notifications_receiver_completed_title">Transfer odebrany</string>
|
||||
<string name="notifications_receiver_failed_body">%1$s nie mógł odebrać „%2$s”</string>
|
||||
<string name="notifications_receiver_failed_title">Dostarczenie nie powiodło się</string>
|
||||
<string name="notifications_send_failed_body">Nie udało się udostępnić „%1$s”.</string>
|
||||
<string name="notifications_send_failed_title">Udostępnianie nie powiodło się</string>
|
||||
<string name="notifications_settings_open_failed">Nie udało się otworzyć ustawień powiadomień.</string>
|
||||
<string name="notifications_title">Powiadomienia</string>
|
||||
<string name="notifications_unsupported">Powiadomienia nie są dostępne na tym urządzeniu.</string>
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<string name="bug_report_what_label">O que aconteceu?</string>
|
||||
<string name="button_approve">Aprovar</string>
|
||||
<string name="button_back">Voltar</string>
|
||||
<string name="button_more_actions">Mais ações</string>
|
||||
<string name="button_cancel">Cancelar</string>
|
||||
<string name="button_cancel_receive">Cancelar</string>
|
||||
<string name="button_change_files">Alterar ficheiros</string>
|
||||
@@ -122,6 +123,16 @@
|
||||
<string name="notifications_enabled_message">Notificações ativadas.</string>
|
||||
<string name="notifications_local_title">Permitir notificações</string>
|
||||
<string name="notifications_permission_denied">As notificações estão desativadas para o VniDrop. Pode ativá-las nas Definições.</string>
|
||||
<string name="notifications_receive_completed_body">«%1$s» concluiu a transferência.</string>
|
||||
<string name="notifications_receive_completed_title">Transferência concluída</string>
|
||||
<string name="notifications_receive_failed_body">Não foi possível receber «%1$s».</string>
|
||||
<string name="notifications_receive_failed_title">Falha na transferência</string>
|
||||
<string name="notifications_receiver_completed_body">%1$s terminou de receber «%2$s».</string>
|
||||
<string name="notifications_receiver_completed_title">Transferência recebida</string>
|
||||
<string name="notifications_receiver_failed_body">%1$s não conseguiu receber “%2$s”</string>
|
||||
<string name="notifications_receiver_failed_title">Falha na entrega</string>
|
||||
<string name="notifications_send_failed_body">Não foi possível partilhar «%1$s».</string>
|
||||
<string name="notifications_send_failed_title">Falha na partilha</string>
|
||||
<string name="notifications_settings_open_failed">Não foi possível abrir as definições de notificações.</string>
|
||||
<string name="notifications_title">Notificações</string>
|
||||
<string name="notifications_unsupported">As notificações não estão disponíveis neste dispositivo.</string>
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<string name="bug_report_what_label">Что произошло?</string>
|
||||
<string name="button_approve">Одобрить</string>
|
||||
<string name="button_back">Назад</string>
|
||||
<string name="button_more_actions">Другие действия</string>
|
||||
<string name="button_cancel">Отмена</string>
|
||||
<string name="button_cancel_receive">Отмена</string>
|
||||
<string name="button_change_files">Изменить файлы</string>
|
||||
@@ -122,6 +123,16 @@
|
||||
<string name="notifications_enabled_message">Уведомления включены.</string>
|
||||
<string name="notifications_local_title">Разрешить уведомления</string>
|
||||
<string name="notifications_permission_denied">Уведомления отключены для VniDrop. Вы можете включить их в Настройках.</string>
|
||||
<string name="notifications_receive_completed_body">«%1$s» завершил загрузку.</string>
|
||||
<string name="notifications_receive_completed_title">Загрузка завершена</string>
|
||||
<string name="notifications_receive_failed_body">Не удалось получить «%1$s».</string>
|
||||
<string name="notifications_receive_failed_title">Ошибка загрузки</string>
|
||||
<string name="notifications_receiver_completed_body">%1$s завершил получение «%2$s».</string>
|
||||
<string name="notifications_receiver_completed_title">Передача получена</string>
|
||||
<string name="notifications_receiver_failed_body">%1$s не удалось получить «%2$s»</string>
|
||||
<string name="notifications_receiver_failed_title">Ошибка доставки</string>
|
||||
<string name="notifications_send_failed_body">Не удалось поделиться «%1$s».</string>
|
||||
<string name="notifications_send_failed_title">Не удалось поделиться</string>
|
||||
<string name="notifications_settings_open_failed">Не удалось открыть настройки уведомлений.</string>
|
||||
<string name="notifications_title">Уведомления</string>
|
||||
<string name="notifications_unsupported">Уведомления недоступны на этом устройстве.</string>
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<string name="bug_report_what_label">What happened?</string>
|
||||
<string name="button_approve">Approve</string>
|
||||
<string name="button_back">Back</string>
|
||||
<string name="button_more_actions">More actions</string>
|
||||
<string name="button_cancel">Cancel</string>
|
||||
<string name="button_cancel_receive">Cancel</string>
|
||||
<string name="button_change_files">Change files</string>
|
||||
@@ -122,6 +123,16 @@
|
||||
<string name="notifications_enabled_message">Notifications enabled.</string>
|
||||
<string name="notifications_local_title">Allow notifications</string>
|
||||
<string name="notifications_permission_denied">Notifications are turned off for VniDrop. You can enable them in Settings.</string>
|
||||
<string name="notifications_receive_completed_body">“%1$s” finished downloading.</string>
|
||||
<string name="notifications_receive_completed_title">Download complete</string>
|
||||
<string name="notifications_receive_failed_body">“%1$s” couldn’t be received.</string>
|
||||
<string name="notifications_receive_failed_title">Download failed</string>
|
||||
<string name="notifications_receiver_completed_body">%1$s finished receiving “%2$s”.</string>
|
||||
<string name="notifications_receiver_completed_title">Transfer received</string>
|
||||
<string name="notifications_receiver_failed_body">%1$s couldn\'t receive “%2$s”</string>
|
||||
<string name="notifications_receiver_failed_title">Delivery failed</string>
|
||||
<string name="notifications_send_failed_body">“%1$s” couldn’t be shared.</string>
|
||||
<string name="notifications_send_failed_title">Sharing failed</string>
|
||||
<string name="notifications_settings_open_failed">Could not open notification settings.</string>
|
||||
<string name="notifications_title">Notifications</string>
|
||||
<string name="notifications_unsupported">Notifications are not available on this device.</string>
|
||||
|
||||
@@ -6,6 +6,13 @@ import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -14,6 +21,8 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -49,6 +58,9 @@ import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.app_starting
|
||||
|
||||
@Composable
|
||||
fun App(
|
||||
@@ -217,6 +229,32 @@ fun App(
|
||||
)
|
||||
}
|
||||
windowChrome?.invoke()
|
||||
val startingLabel = stringResource(Res.string.app_starting)
|
||||
AnimatedVisibility(
|
||||
visible = !sendCoreState.isInitialized,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(LocalVniDropColors.current.backgroundSurface100)
|
||||
.semantics { contentDescription = startingLabel },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
startingLabel,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.vnidrop.app.feature.approvals.ApprovalCoordinator
|
||||
import com.vnidrop.app.feature.send.AppFilePreviewRepository
|
||||
import com.vnidrop.app.feature.send.createPlatformPreviewStore
|
||||
import com.vnidrop.app.logging.AppLogger
|
||||
import com.vnidrop.app.notifications.TransferNotificationCoordinator
|
||||
import com.vnidrop.app.platform.AppVisibility
|
||||
import com.vnidrop.app.preferences.AppPreferencesDefaults
|
||||
import com.vnidrop.app.preferences.AppPreferencesRepository
|
||||
@@ -62,6 +63,14 @@ class AppGraph(
|
||||
messages = messages,
|
||||
scope = applicationScope,
|
||||
)
|
||||
val transferNotificationCoordinator = TransferNotificationCoordinator(
|
||||
repository = coreRepository,
|
||||
preferencesRepository = preferencesRepository,
|
||||
notifications = dependencies.localNotificationService,
|
||||
visibility = visibility,
|
||||
messages = messages,
|
||||
scope = applicationScope,
|
||||
)
|
||||
|
||||
init {
|
||||
AppLogger.initialize(dependencies.environment.defaultCoreDataDir)
|
||||
|
||||
@@ -41,6 +41,8 @@ interface FileSystemService {
|
||||
suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus
|
||||
suspend fun inspectReceivedArtifacts(artifacts: List<ReceivedArtifactModel>): ReceivedStorageInspection
|
||||
suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong
|
||||
/** Reclaims only app-owned temporary files and returns the number of bytes removed. */
|
||||
suspend fun reclaimTemporaryStorage(appDataDir: String, receiveFolder: ReceiveFolder): ULong
|
||||
fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2?
|
||||
fun canRevealReceiveFolder(folder: ReceiveFolder): Boolean = false
|
||||
suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> =
|
||||
|
||||
@@ -294,7 +294,7 @@ private fun InvitationReviewPanel(
|
||||
Text(
|
||||
when (error) {
|
||||
is UiText.Dynamic -> error.value
|
||||
is UiText.Resource -> stringResource(error.resource)
|
||||
is UiText.Resource -> stringResource(error.resource, *error.formatArgs.toTypedArray())
|
||||
},
|
||||
color = LocalVniDropColors.current.destructiveDefault,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
|
||||
@@ -19,17 +19,25 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -58,12 +66,16 @@ import org.jetbrains.compose.resources.stringResource
|
||||
import org.jetbrains.compose.resources.decodeToImageBitmap
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.button_create_new_transfer
|
||||
import vnidrop.shared.generated.resources.button_delete_transfer
|
||||
import vnidrop.shared.generated.resources.button_more_actions
|
||||
import vnidrop.shared.generated.resources.send_empty_body
|
||||
import vnidrop.shared.generated.resources.send_empty_title
|
||||
import vnidrop.shared.generated.resources.send_new_transfer_description
|
||||
import vnidrop.shared.generated.resources.send_new_transfer_title
|
||||
import vnidrop.shared.generated.resources.send_title
|
||||
import vnidrop.shared.generated.resources.send_stop_sharing
|
||||
import vnidrop.shared.generated.resources.send_transfers_title
|
||||
import vnidrop.shared.generated.resources.transfer_share_title
|
||||
|
||||
@Composable
|
||||
internal fun SendFloatingAction(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
@@ -86,6 +98,9 @@ internal fun TransferCatalog(
|
||||
windowClass: WindowClass,
|
||||
onOpenComposer: () -> Unit,
|
||||
onTransferSelected: (ULong) -> Unit,
|
||||
onShare: (ULong) -> Unit = {},
|
||||
onStopSharing: (ULong) -> Unit = {},
|
||||
onDelete: (ULong) -> Unit = {},
|
||||
) {
|
||||
val usesFloatingAction = usesMobilePresentation(LocalUiPlatform.current, windowClass)
|
||||
LazyColumn(
|
||||
@@ -129,6 +144,9 @@ internal fun TransferCatalog(
|
||||
thumbnailBytes = transferThumbnails[transfer.transferId],
|
||||
progress = progress,
|
||||
onClick = { onTransferSelected(transfer.transferId) },
|
||||
onShare = { onShare(transfer.transferId) },
|
||||
onStopSharing = { onStopSharing(transfer.transferId) },
|
||||
onDelete = { onDelete(transfer.transferId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -192,6 +210,9 @@ private fun TransferListItem(
|
||||
thumbnailBytes: ByteArray?,
|
||||
progress: TransferProgress?,
|
||||
onClick: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onStopSharing: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val colors = LocalVniDropColors.current
|
||||
Surface(onClick = onClick, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = colors.backgroundSurface200) {
|
||||
@@ -227,8 +248,68 @@ private fun TransferListItem(
|
||||
ProgressRow(label = progress.label, progress = progress.progress, detail = progress.detail)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
PlatformIcon(AppIcon.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(18.dp))
|
||||
TransferActionsMenu(transfer, onShare, onStopSharing, onDelete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransferActionsMenu(
|
||||
transfer: Transfer,
|
||||
onShare: () -> Unit,
|
||||
onStopSharing: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val moreActionsLabel = stringResource(Res.string.button_more_actions)
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { expanded = true },
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = moreActionsLabel
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
"⋮",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
if (transfer.ticket != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.transfer_share_title)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onShare()
|
||||
},
|
||||
leadingIcon = { PlatformIcon(AppIcon.Send, contentDescription = null) },
|
||||
)
|
||||
}
|
||||
if (transfer.status == TransferStatus.Sharing) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.send_stop_sharing)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onStopSharing()
|
||||
},
|
||||
leadingIcon = { PlatformIcon(AppIcon.Close, contentDescription = null) },
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.button_delete_transfer)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onDelete()
|
||||
},
|
||||
leadingIcon = {
|
||||
PlatformIcon(
|
||||
AppIcon.Delete,
|
||||
contentDescription = null,
|
||||
tint = LocalVniDropColors.current.destructiveDefault,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,11 @@ fun SendRoute(
|
||||
onAccessPolicyChanged = viewModel::setAccessPolicy,
|
||||
onCreateShare = viewModel::createShare,
|
||||
onTransferSelected = viewModel::openTransfer,
|
||||
onShareTransfer = { transferId ->
|
||||
viewModel.openTransfer(transferId)
|
||||
viewModel.openShare()
|
||||
},
|
||||
onStopSharing = viewModel::stopSharing,
|
||||
onCloseTransferDetails = viewModel::closeTransferDetails,
|
||||
onCopyTicket = viewModel::copyTicket,
|
||||
onActivity = viewModel::openActivity,
|
||||
@@ -53,7 +58,8 @@ fun SendRoute(
|
||||
onShare = viewModel::openShare,
|
||||
onCloseDetailPanel = viewModel::closeDetailPanel,
|
||||
onInvitationResult = viewModel::onInvitationResult,
|
||||
onRequestDelete = viewModel::requestDeleteTransfer,
|
||||
onRequestDelete = { viewModel.requestDeleteTransfer() },
|
||||
onRequestDeleteTransfer = { viewModel.requestDeleteTransfer(it) },
|
||||
onDismissDelete = viewModel::dismissDeleteTransfer,
|
||||
onConfirmDelete = viewModel::confirmDeleteTransfer,
|
||||
)
|
||||
|
||||
@@ -33,6 +33,8 @@ fun SendScreen(
|
||||
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
|
||||
onCreateShare: () -> Unit,
|
||||
onTransferSelected: (ULong) -> Unit,
|
||||
onShareTransfer: (ULong) -> Unit = {},
|
||||
onStopSharing: (ULong) -> Unit = {},
|
||||
onCloseTransferDetails: () -> Unit,
|
||||
onCopyTicket: (String) -> Unit,
|
||||
onActivity: () -> Unit = {},
|
||||
@@ -41,11 +43,13 @@ fun SendScreen(
|
||||
onCloseDetailPanel: () -> Unit = {},
|
||||
onInvitationResult: (InvitationAction, Result<Unit>) -> Unit = { _, _ -> },
|
||||
onRequestDelete: () -> Unit = {},
|
||||
onRequestDeleteTransfer: (ULong) -> Unit = {},
|
||||
onDismissDelete: () -> Unit = {},
|
||||
onConfirmDelete: () -> Unit = {},
|
||||
) {
|
||||
val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send }
|
||||
val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } }
|
||||
val deleteTarget = state.deleteTargetTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } }
|
||||
val qrCache = remember { mutableStateMapOf<String, ImageBitmap>() }
|
||||
LaunchedEffect(outgoingTransfers.mapNotNull { it.ticket }) {
|
||||
qrCache.keys.retainAll(outgoingTransfers.mapNotNull { it.ticket }.toSet())
|
||||
@@ -64,6 +68,7 @@ fun SendScreen(
|
||||
onActivity = onActivity,
|
||||
onReceivers = onReceivers,
|
||||
onShare = onShare,
|
||||
onStopSharing = { onStopSharing(selectedTransfer.transferId) },
|
||||
onDelete = onRequestDelete,
|
||||
)
|
||||
} else {
|
||||
@@ -75,6 +80,9 @@ fun SendScreen(
|
||||
windowClass = windowClass,
|
||||
onOpenComposer = onOpenComposer,
|
||||
onTransferSelected = onTransferSelected,
|
||||
onShare = onShareTransfer,
|
||||
onStopSharing = onStopSharing,
|
||||
onDelete = onRequestDeleteTransfer,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -123,10 +131,10 @@ fun SendScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedTransfer != null && state.isDeleteConfirmationOpen) {
|
||||
if (deleteTarget != null && state.isDeleteConfirmationOpen) {
|
||||
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismissDelete) {
|
||||
DeleteTransferPanel(
|
||||
transferName = selectedTransfer.transferName,
|
||||
transferName = deleteTarget.transferName,
|
||||
isDeleting = state.isDeleting,
|
||||
onCancel = onDismissDelete,
|
||||
onConfirm = onConfirmDelete,
|
||||
|
||||
@@ -44,6 +44,7 @@ data class SendState(
|
||||
val receiversByTransfer: Map<ULong, List<ReceiverRequestModel>> = emptyMap(),
|
||||
val isLoadingReceivers: Boolean = false,
|
||||
val isDeleteConfirmationOpen: Boolean = false,
|
||||
val deleteTargetTransferId: ULong? = null,
|
||||
val isDeleting: Boolean = false,
|
||||
) {
|
||||
val selectedFile: PickedShareFile? get() = selectedFiles.singleOrNull()
|
||||
@@ -221,12 +222,19 @@ class SendViewModel(
|
||||
refreshReceivers(transferId)
|
||||
}
|
||||
fun closeDetailPanel() = _state.update { it.copy(detailPanel = null) }
|
||||
fun requestDeleteTransfer() = _state.update { it.copy(isDeleteConfirmationOpen = true) }
|
||||
fun requestDeleteTransfer(transferId: ULong? = null) = _state.update {
|
||||
it.copy(
|
||||
isDeleteConfirmationOpen = true,
|
||||
deleteTargetTransferId = transferId ?: it.selectedTransferId,
|
||||
)
|
||||
}
|
||||
fun dismissDeleteTransfer() {
|
||||
if (!_state.value.isDeleting) _state.update { it.copy(isDeleteConfirmationOpen = false) }
|
||||
if (!_state.value.isDeleting) {
|
||||
_state.update { it.copy(isDeleteConfirmationOpen = false, deleteTargetTransferId = null) }
|
||||
}
|
||||
}
|
||||
fun confirmDeleteTransfer() {
|
||||
val transferId = _state.value.selectedTransferId ?: return
|
||||
val transferId = _state.value.deleteTargetTransferId ?: return
|
||||
if (_state.value.isDeleting) return
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isDeleting = true) }
|
||||
@@ -239,6 +247,7 @@ class SendViewModel(
|
||||
detailPanel = null,
|
||||
receiverHistory = emptyList(),
|
||||
isDeleteConfirmationOpen = false,
|
||||
deleteTargetTransferId = null,
|
||||
isDeleting = false,
|
||||
)
|
||||
}
|
||||
@@ -285,6 +294,7 @@ class SendViewModel(
|
||||
onSuccess = { share ->
|
||||
current.selectedFiles.firstNotNullOfOrNull { it.thumbnailBytes }
|
||||
?.let { filePreviewRepository.save(share.transferId, it) }
|
||||
repository.refresh()
|
||||
_state.update {
|
||||
it.copy(
|
||||
isComposerOpen = false,
|
||||
@@ -292,8 +302,11 @@ class SendViewModel(
|
||||
transferName = "",
|
||||
accessPolicy = ShareAccessPolicy.RequireApproval,
|
||||
isSharing = false,
|
||||
selectedTransferId = share.transferId,
|
||||
detailPanel = TransferDetailPanel.Share,
|
||||
)
|
||||
}
|
||||
refreshReceivers(share.transferId)
|
||||
messages.show(UiMessage(UiText.Resource(Res.string.send_transfer_created), UiMessageTone.Success))
|
||||
},
|
||||
onFailure = { error ->
|
||||
@@ -304,6 +317,15 @@ class SendViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun stopSharing(transferId: ULong) {
|
||||
viewModelScope.launch {
|
||||
repository.cancel(transferId).fold(
|
||||
onSuccess = { repository.refresh() },
|
||||
onFailure = messages::error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultTransferName(files: List<PickedShareFile>): String = when {
|
||||
files.isEmpty() -> ""
|
||||
files.size == 1 && files.first().isDirectory -> files.first().displayName
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -174,22 +175,51 @@ private fun ReviewFileStep(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (windowClass == WindowClass.Phone) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth())
|
||||
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing)
|
||||
QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing)
|
||||
}
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ShareButton(state, coreInitialized, onCreateShare)
|
||||
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, enabled = !state.isSharing)
|
||||
QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder, enabled = !state.isSharing)
|
||||
QuietButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = !state.isSharing)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth())
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SourceButton(
|
||||
text = stringResource(Res.string.button_change_files),
|
||||
icon = AppIcon.File,
|
||||
onClick = onSelectFile,
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = !state.isSharing,
|
||||
)
|
||||
SourceButton(
|
||||
text = stringResource(Res.string.button_choose_folder),
|
||||
icon = AppIcon.Folder,
|
||||
onClick = onSelectFolder,
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = !state.isSharing,
|
||||
)
|
||||
if (windowClass != WindowClass.Phone) {
|
||||
SourceButton(
|
||||
text = stringResource(Res.string.button_clear),
|
||||
icon = AppIcon.Close,
|
||||
onClick = onClearFile,
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = !state.isSharing,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SourceButton(
|
||||
text: String,
|
||||
icon: AppIcon,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
OutlinedButton(onClick = onClick, modifier = modifier, enabled = enabled) {
|
||||
PlatformIcon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareButton(state: SendState, coreInitialized: Boolean, onCreateShare: () -> Unit, modifier: Modifier = Modifier) {
|
||||
PrimaryButton(
|
||||
|
||||
@@ -84,6 +84,7 @@ internal fun TransferDetails(
|
||||
onActivity: () -> Unit,
|
||||
onReceivers: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onStopSharing: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
@@ -100,8 +101,14 @@ internal fun TransferDetails(
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
IconButton(onClick = onDelete) {
|
||||
PlatformIcon(AppIcon.Delete, stringResource(Res.string.button_delete_transfer), tint = LocalVniDropColors.current.destructiveDefault)
|
||||
if (transfer.status in setOf(TransferStatus.Importing, TransferStatus.Sharing)) {
|
||||
IconButton(onClick = onShare) {
|
||||
PlatformIcon(
|
||||
AppIcon.Send,
|
||||
stringResource(Res.string.transfer_share_title),
|
||||
tint = LocalVniDropColors.current.brandLink,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,32 +137,25 @@ internal fun TransferDetails(
|
||||
count = pendingReceivers + completedReceivers,
|
||||
onClick = onReceivers,
|
||||
)
|
||||
when (transfer.status) {
|
||||
TransferStatus.Sharing -> {
|
||||
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
||||
DetailDestination(
|
||||
title = stringResource(Res.string.transfer_share_title),
|
||||
description = stringResource(Res.string.transfer_share_description),
|
||||
onClick = onShare,
|
||||
)
|
||||
}
|
||||
TransferStatus.Importing -> {
|
||||
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
||||
DetailDestination(
|
||||
title = stringResource(Res.string.transfer_share_title),
|
||||
description = stringResource(Res.string.transfer_event_preparing),
|
||||
)
|
||||
}
|
||||
TransferStatus.Receiving,
|
||||
TransferStatus.Done,
|
||||
TransferStatus.Failed,
|
||||
TransferStatus.Cancelled,
|
||||
TransferStatus.Stopped,
|
||||
-> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (transfer.status == TransferStatus.Sharing) {
|
||||
DestructiveButton(
|
||||
stringResource(Res.string.send_stop_sharing),
|
||||
onClick = onStopSharing,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
DestructiveButton(
|
||||
stringResource(Res.string.button_delete_transfer),
|
||||
onClick = onDelete,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,5 +42,7 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) {
|
||||
onSubmitBugReport = viewModel::submitBugReport,
|
||||
onDeleteAllTransfers = viewModel::deleteAllTransfers,
|
||||
onClearTransferCache = viewModel::clearTransferCache,
|
||||
onFreeUpSpace = viewModel::freeUpSpace,
|
||||
onRefreshStorage = viewModel::loadStorageUsage,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ fun SettingsScreen(
|
||||
onSubmitBugReport: () -> Unit,
|
||||
onDeleteAllTransfers: () -> Unit = {},
|
||||
onClearTransferCache: () -> Unit = {},
|
||||
onFreeUpSpace: () -> Unit = {},
|
||||
onRefreshStorage: () -> Unit = {},
|
||||
onRelayModeChanged: (RelayMode) -> Unit = {},
|
||||
onRelayUrlChanged: (Int, String) -> Unit = { _, _ -> },
|
||||
onAddRelayUrl: () -> Unit = {},
|
||||
@@ -69,6 +71,8 @@ fun SettingsScreen(
|
||||
onSubmitBugReport = onSubmitBugReport,
|
||||
onDeleteAllTransfers = onDeleteAllTransfers,
|
||||
onClearTransferCache = onClearTransferCache,
|
||||
onFreeUpSpace = onFreeUpSpace,
|
||||
onRefreshStorage = onRefreshStorage,
|
||||
onRelayModeChanged = onRelayModeChanged,
|
||||
onRelayUrlChanged = onRelayUrlChanged,
|
||||
onAddRelayUrl = onAddRelayUrl,
|
||||
@@ -110,6 +114,8 @@ fun SettingsScreen(
|
||||
onSubmitBugReport = onSubmitBugReport,
|
||||
onDeleteAllTransfers = onDeleteAllTransfers,
|
||||
onClearTransferCache = onClearTransferCache,
|
||||
onFreeUpSpace = onFreeUpSpace,
|
||||
onRefreshStorage = onRefreshStorage,
|
||||
onRelayModeChanged = onRelayModeChanged,
|
||||
onRelayUrlChanged = onRelayUrlChanged,
|
||||
onAddRelayUrl = onAddRelayUrl,
|
||||
@@ -143,6 +149,8 @@ private fun SettingsSectionContent(
|
||||
onSubmitBugReport: () -> Unit,
|
||||
onDeleteAllTransfers: () -> Unit,
|
||||
onClearTransferCache: () -> Unit,
|
||||
onFreeUpSpace: () -> Unit,
|
||||
onRefreshStorage: () -> Unit,
|
||||
onRelayModeChanged: (RelayMode) -> Unit,
|
||||
onRelayUrlChanged: (Int, String) -> Unit,
|
||||
onAddRelayUrl: () -> Unit,
|
||||
@@ -169,6 +177,8 @@ private fun SettingsSectionContent(
|
||||
windowClass,
|
||||
onDeleteAllTransfers,
|
||||
onClearTransferCache,
|
||||
onFreeUpSpace,
|
||||
onRefreshStorage,
|
||||
onBack,
|
||||
showBack,
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ 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 com.vnidrop.app.ui.state.formatBytes
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@@ -51,6 +52,8 @@ import vnidrop.shared.generated.resources.notifications_unsupported
|
||||
import vnidrop.shared.generated.resources.relay_settings_applied
|
||||
import vnidrop.shared.generated.resources.storage_transfer_cache_cleared
|
||||
import vnidrop.shared.generated.resources.storage_transfers_deleted
|
||||
import vnidrop.shared.generated.resources.storage_cleanup_busy
|
||||
import vnidrop.shared.generated.resources.storage_cleanup_freed
|
||||
|
||||
enum class SettingsSection {
|
||||
Overview,
|
||||
@@ -112,8 +115,10 @@ data class SettingsState(
|
||||
val bugLogPreviewBytes: Int = 0,
|
||||
val storage: StorageBreakdown? = null,
|
||||
val isCalculatingStorage: Boolean = false,
|
||||
val storageLoadFailed: Boolean = false,
|
||||
val isDeletingTransfers: Boolean = false,
|
||||
val isClearingTransferCache: Boolean = false,
|
||||
val isCleaningStorage: Boolean = false,
|
||||
) {
|
||||
val hasRelaySettingsChanges: Boolean
|
||||
get() = relayMode != savedRelaySettings.mode ||
|
||||
@@ -188,6 +193,14 @@ class SettingsViewModel(
|
||||
endpointId = coreState.status?.endpointId,
|
||||
)
|
||||
}
|
||||
if (
|
||||
coreState.isInitialized &&
|
||||
_state.value.selectedSection == SettingsSection.Storage &&
|
||||
_state.value.storage == null &&
|
||||
_state.value.storageLoadFailed
|
||||
) {
|
||||
loadStorageUsage()
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshNotificationPermission()
|
||||
@@ -209,7 +222,7 @@ class SettingsViewModel(
|
||||
fun loadStorageUsage() {
|
||||
if (_state.value.isCalculatingStorage) return
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isCalculatingStorage = true) }
|
||||
_state.update { it.copy(isCalculatingStorage = true, storageLoadFailed = false) }
|
||||
try {
|
||||
val receiveFolder = _state.value.receiveFolder ?: fileSystemService.defaultReceiveFolder()
|
||||
val coreUsage = repository.storageUsage().getOrThrow()
|
||||
@@ -227,12 +240,42 @@ class SettingsViewModel(
|
||||
inaccessibleReceivedFileCount = received.inaccessibleCount,
|
||||
),
|
||||
isCalculatingStorage = false,
|
||||
storageLoadFailed = false,
|
||||
)
|
||||
}
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Throwable) {
|
||||
_state.update { it.copy(isCalculatingStorage = false) }
|
||||
_state.update { it.copy(isCalculatingStorage = false, storageLoadFailed = true) }
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun freeUpSpace() {
|
||||
val current = _state.value
|
||||
if (current.isCleaningStorage) return
|
||||
if (current.hasActiveNetworkWork) {
|
||||
messages.tryShow(UiMessage(UiText.Resource(Res.string.storage_cleanup_busy), UiMessageTone.Warning))
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isCleaningStorage = true) }
|
||||
try {
|
||||
val receiveFolder = _state.value.receiveFolder ?: fileSystemService.defaultReceiveFolder()
|
||||
val reclaimed = fileSystemService.reclaimTemporaryStorage(environment.defaultCoreDataDir, receiveFolder)
|
||||
_state.update { it.copy(isCleaningStorage = false) }
|
||||
loadStorageUsage()
|
||||
messages.tryShow(
|
||||
UiMessage(
|
||||
UiText.Resource(Res.string.storage_cleanup_freed, formatArgs = listOf(formatBytes(reclaimed))),
|
||||
UiMessageTone.Success,
|
||||
),
|
||||
)
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Throwable) {
|
||||
_state.update { it.copy(isCleaningStorage = false) }
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,9 +36,16 @@ import vnidrop.shared.generated.resources.storage_calculating
|
||||
import vnidrop.shared.generated.resources.storage_clear_transfer_cache
|
||||
import vnidrop.shared.generated.resources.storage_clear_transfer_cache_description
|
||||
import vnidrop.shared.generated.resources.storage_clearing_transfer_cache
|
||||
import vnidrop.shared.generated.resources.storage_cleaning
|
||||
import vnidrop.shared.generated.resources.storage_delete_transfers_caption
|
||||
import vnidrop.shared.generated.resources.storage_delete_transfers
|
||||
import vnidrop.shared.generated.resources.storage_delete_transfers_description
|
||||
import vnidrop.shared.generated.resources.storage_deleting
|
||||
import vnidrop.shared.generated.resources.storage_free_up_space
|
||||
import vnidrop.shared.generated.resources.storage_free_up_space_caption
|
||||
import vnidrop.shared.generated.resources.storage_refresh
|
||||
import vnidrop.shared.generated.resources.storage_unavailable
|
||||
import vnidrop.shared.generated.resources.storage_usage_header
|
||||
import vnidrop.shared.generated.resources.storage_total
|
||||
import vnidrop.shared.generated.resources.storage_received_files
|
||||
import vnidrop.shared.generated.resources.storage_temporary
|
||||
@@ -52,6 +59,8 @@ internal fun StorageSettings(
|
||||
windowClass: WindowClass,
|
||||
onDeleteAllTransfers: () -> Unit,
|
||||
onClearTransferCache: () -> Unit,
|
||||
onFreeUpSpace: () -> Unit,
|
||||
onRefreshStorage: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
showBack: Boolean,
|
||||
) {
|
||||
@@ -59,8 +68,30 @@ internal fun StorageSettings(
|
||||
var showClearCacheConfirmation by rememberSaveable { mutableStateOf(false) }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
SettingsTopBar(stringResource(Res.string.storage_title), onBack, showBack)
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stringResource(Res.string.storage_usage_header),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
SecondaryButton(
|
||||
stringResource(Res.string.storage_refresh),
|
||||
onClick = onRefreshStorage,
|
||||
enabled = !state.isCalculatingStorage &&
|
||||
!state.isCleaningStorage &&
|
||||
!state.isDeletingTransfers &&
|
||||
!state.isClearingTransferCache,
|
||||
)
|
||||
}
|
||||
val storage = state.storage
|
||||
if (storage == null || state.isCalculatingStorage) {
|
||||
if (storage == null && state.storageLoadFailed && !state.isCalculatingStorage) {
|
||||
SecondaryButton(
|
||||
stringResource(Res.string.storage_unavailable),
|
||||
onClick = onRefreshStorage,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else if (storage == null || state.isCalculatingStorage) {
|
||||
SettingsGroup {
|
||||
StorageRow(
|
||||
title = stringResource(Res.string.storage_calculating),
|
||||
@@ -84,6 +115,23 @@ internal fun StorageSettings(
|
||||
)
|
||||
}
|
||||
}
|
||||
SecondaryButton(
|
||||
text = stringResource(
|
||||
if (state.isCleaningStorage) Res.string.storage_cleaning else Res.string.storage_free_up_space,
|
||||
),
|
||||
onClick = onFreeUpSpace,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !state.isCleaningStorage &&
|
||||
!state.isDeletingTransfers &&
|
||||
!state.isClearingTransferCache &&
|
||||
!state.isCalculatingStorage &&
|
||||
!state.hasActiveNetworkWork,
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.storage_free_up_space_caption),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
)
|
||||
SecondaryButton(
|
||||
text = stringResource(
|
||||
if (state.isClearingTransferCache) {
|
||||
@@ -107,6 +155,11 @@ internal fun StorageSettings(
|
||||
) {
|
||||
Text(stringResource(if (state.isDeletingTransfers) Res.string.storage_deleting else Res.string.storage_delete_transfers))
|
||||
}
|
||||
Text(
|
||||
stringResource(Res.string.storage_delete_transfers_caption),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.storage_footer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package com.vnidrop.app.notifications
|
||||
|
||||
import com.vnidrop.app.core.CoreGateway
|
||||
import com.vnidrop.app.core.CoreSignal
|
||||
import com.vnidrop.app.core.ReceiverDeliveryStatus
|
||||
import com.vnidrop.app.core.ReceiverRequestModel
|
||||
import com.vnidrop.app.core.Transfer
|
||||
import com.vnidrop.app.core.TransferDirection
|
||||
import com.vnidrop.app.core.TransferStatus
|
||||
import com.vnidrop.app.platform.AppVisibility
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.approval_nearby_device
|
||||
import vnidrop.shared.generated.resources.notifications_receive_completed_body
|
||||
import vnidrop.shared.generated.resources.notifications_receive_completed_title
|
||||
import vnidrop.shared.generated.resources.notifications_receive_failed_body
|
||||
import vnidrop.shared.generated.resources.notifications_receive_failed_title
|
||||
import vnidrop.shared.generated.resources.notifications_receiver_completed_body
|
||||
import vnidrop.shared.generated.resources.notifications_receiver_completed_title
|
||||
import vnidrop.shared.generated.resources.notifications_receiver_failed_body
|
||||
import vnidrop.shared.generated.resources.notifications_receiver_failed_title
|
||||
import vnidrop.shared.generated.resources.notifications_send_failed_body
|
||||
import vnidrop.shared.generated.resources.notifications_send_failed_title
|
||||
import vnidrop.shared.generated.resources.receive_unknown_transfer
|
||||
|
||||
internal enum class TransferNotificationKind {
|
||||
SendFailed,
|
||||
ReceiveCompleted,
|
||||
ReceiveFailed,
|
||||
ReceiverCompleted,
|
||||
ReceiverFailed,
|
||||
}
|
||||
|
||||
internal data class PlannedTransferNotification(
|
||||
val id: String,
|
||||
val kind: TransferNotificationKind,
|
||||
val transferName: String?,
|
||||
val receiver: String? = null,
|
||||
)
|
||||
|
||||
internal fun plannedTransferNotifications(
|
||||
transfers: List<Transfer>,
|
||||
published: Set<String>,
|
||||
): List<PlannedTransferNotification> = transfers.mapNotNull { transfer ->
|
||||
val kind = when {
|
||||
transfer.direction == TransferDirection.Send && transfer.status == TransferStatus.Failed ->
|
||||
TransferNotificationKind.SendFailed
|
||||
transfer.direction == TransferDirection.Receive && transfer.status == TransferStatus.Done ->
|
||||
TransferNotificationKind.ReceiveCompleted
|
||||
transfer.direction == TransferDirection.Receive && transfer.status == TransferStatus.Failed ->
|
||||
TransferNotificationKind.ReceiveFailed
|
||||
else -> return@mapNotNull null
|
||||
}
|
||||
val id = "${kind.idPrefix}-${transfer.transferId}"
|
||||
PlannedTransferNotification(id, kind, transfer.transferName).takeUnless { id in published }
|
||||
}
|
||||
|
||||
internal fun plannedReceiverNotifications(
|
||||
requests: List<ReceiverRequestModel>,
|
||||
published: Set<String>,
|
||||
): List<PlannedTransferNotification> = requests.mapNotNull { request ->
|
||||
val kind = when (request.status) {
|
||||
ReceiverDeliveryStatus.Completed -> TransferNotificationKind.ReceiverCompleted
|
||||
ReceiverDeliveryStatus.Failed -> TransferNotificationKind.ReceiverFailed
|
||||
else -> return@mapNotNull null
|
||||
}
|
||||
val id = "${kind.idPrefix}-${request.id}"
|
||||
PlannedTransferNotification(
|
||||
id = id,
|
||||
kind = kind,
|
||||
transferName = request.transferName,
|
||||
receiver = request.receiverName ?: request.receiverDeviceName,
|
||||
).takeUnless { id in published }
|
||||
}
|
||||
|
||||
class TransferNotificationCoordinator(
|
||||
private val repository: CoreGateway,
|
||||
private val preferencesRepository: PreferencesRepository,
|
||||
private val notifications: LocalNotificationService,
|
||||
private val visibility: AppVisibility,
|
||||
private val messages: UiMessageController,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val published = mutableSetOf<String>()
|
||||
private var transfersPrimed = false
|
||||
private var notificationsEnabled = false
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
preferencesRepository.preferences.collectLatest { preferences ->
|
||||
notificationsEnabled = preferences.notificationsEnabled
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
repository.state.collect { core ->
|
||||
if (core.isInitialized) syncTransfers(core.transfers)
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
repository.signals.collect { signal ->
|
||||
when (signal) {
|
||||
is CoreSignal.ReceiverHistoryChanged -> syncReceivers(signal.transferId)
|
||||
is CoreSignal.TransfersChanged -> syncReceivers(signal.transferId)
|
||||
is CoreSignal.ApprovalChanged -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncTransfers(transfers: List<Transfer>) {
|
||||
val planned = plannedTransferNotifications(transfers, published)
|
||||
if (!transfersPrimed) {
|
||||
transfersPrimed = true
|
||||
published += planned.map(PlannedTransferNotification::id)
|
||||
return
|
||||
}
|
||||
planned.forEach { deliver(it) }
|
||||
}
|
||||
|
||||
private suspend fun syncReceivers(transferId: ULong) {
|
||||
val isOutgoing = repository.state.value.transfers.any {
|
||||
it.transferId == transferId && it.direction == TransferDirection.Send
|
||||
}
|
||||
if (!isOutgoing) return
|
||||
repository.receiverRequests(transferId).fold(
|
||||
onSuccess = { requests ->
|
||||
plannedReceiverNotifications(requests, published).forEach { deliver(it) }
|
||||
},
|
||||
onFailure = messages::error,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun deliver(plan: PlannedTransferNotification) {
|
||||
published += plan.id
|
||||
if (
|
||||
!notificationsEnabled ||
|
||||
visibility.isForeground.value ||
|
||||
notifications.permission.value != NotificationPermission.Granted
|
||||
) return
|
||||
val transferName = plan.transferName ?: getString(Res.string.receive_unknown_transfer)
|
||||
val notification = when (plan.kind) {
|
||||
TransferNotificationKind.SendFailed -> LocalNotification(
|
||||
plan.id,
|
||||
getString(Res.string.notifications_send_failed_title),
|
||||
getString(Res.string.notifications_send_failed_body, transferName),
|
||||
)
|
||||
TransferNotificationKind.ReceiveCompleted -> LocalNotification(
|
||||
plan.id,
|
||||
getString(Res.string.notifications_receive_completed_title),
|
||||
getString(Res.string.notifications_receive_completed_body, transferName),
|
||||
)
|
||||
TransferNotificationKind.ReceiveFailed -> LocalNotification(
|
||||
plan.id,
|
||||
getString(Res.string.notifications_receive_failed_title),
|
||||
getString(Res.string.notifications_receive_failed_body, transferName),
|
||||
)
|
||||
TransferNotificationKind.ReceiverCompleted -> {
|
||||
val receiver = plan.receiver ?: getString(Res.string.approval_nearby_device)
|
||||
LocalNotification(
|
||||
plan.id,
|
||||
getString(Res.string.notifications_receiver_completed_title),
|
||||
getString(Res.string.notifications_receiver_completed_body, receiver, transferName),
|
||||
)
|
||||
}
|
||||
TransferNotificationKind.ReceiverFailed -> {
|
||||
val receiver = plan.receiver ?: getString(Res.string.approval_nearby_device)
|
||||
LocalNotification(
|
||||
plan.id,
|
||||
getString(Res.string.notifications_receiver_failed_title),
|
||||
getString(Res.string.notifications_receiver_failed_body, receiver, transferName),
|
||||
)
|
||||
}
|
||||
}
|
||||
notifications.publish(notification).onFailure(messages::error)
|
||||
}
|
||||
}
|
||||
|
||||
private val TransferNotificationKind.idPrefix: String
|
||||
get() = when (this) {
|
||||
TransferNotificationKind.SendFailed -> "send-failed"
|
||||
TransferNotificationKind.ReceiveCompleted -> "receive-completed"
|
||||
TransferNotificationKind.ReceiveFailed -> "receive-failed"
|
||||
TransferNotificationKind.ReceiverCompleted -> "receiver-completed"
|
||||
TransferNotificationKind.ReceiverFailed -> "receiver-failed"
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
||||
sealed interface UiText {
|
||||
data class Resource(val resource: StringResource) : UiText
|
||||
data class Resource(val resource: StringResource, val formatArgs: List<Any> = emptyList()) : UiText
|
||||
data class Dynamic(val value: String) : UiText
|
||||
}
|
||||
|
||||
|
||||
@@ -138,5 +138,5 @@ private fun DismissButton(onClick: () -> Unit) {
|
||||
|
||||
private suspend fun UiText.resolve(): String = when (this) {
|
||||
is UiText.Dynamic -> value
|
||||
is UiText.Resource -> getString(resource)
|
||||
is UiText.Resource -> getString(resource, *formatArgs.toTypedArray())
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.vnidrop.app.PlatformEnvironment
|
||||
import com.vnidrop.app.core.CoreState
|
||||
import com.vnidrop.app.core.CoreStatus
|
||||
import com.vnidrop.app.core.CoreSignal
|
||||
import com.vnidrop.app.core.CoreStorageUsageModel
|
||||
import com.vnidrop.app.core.PickedShareFile
|
||||
import com.vnidrop.app.core.ReceiveFolder
|
||||
import com.vnidrop.app.core.ReceiveFolderKind
|
||||
@@ -26,6 +27,7 @@ import com.vnidrop.app.feature.app.AppViewModel
|
||||
import com.vnidrop.app.feature.receive.ReceiveHistoryDeleteTarget
|
||||
import com.vnidrop.app.feature.receive.ReceiveViewModel
|
||||
import com.vnidrop.app.feature.send.SendViewModel
|
||||
import com.vnidrop.app.feature.send.TransferDetailPanel
|
||||
import com.vnidrop.app.feature.settings.SettingsSection
|
||||
import com.vnidrop.app.feature.settings.RelaySettingsApplyError
|
||||
import com.vnidrop.app.feature.settings.RelaySettingsInputError
|
||||
@@ -168,6 +170,43 @@ class ViewModelsTest {
|
||||
assertEquals(0, core.clearTransferCacheCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsFreesOnlyPlatformOwnedTemporaryStorage() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val fileSystem = FakeFileSystemService(folder).apply {
|
||||
reclaimedTemporaryBytes = 12_000UL
|
||||
}
|
||||
val viewModel = settingsViewModel(fileSystem = fileSystem)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.freeUpSpace()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, fileSystem.reclaimTemporaryStorageCount)
|
||||
assertFalse(viewModel.state.value.isCleaningStorage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsRetriesStorageAfterCoreFinishesStarting() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val core = FakeCoreGateway().apply {
|
||||
storageUsageResult = Result.failure(IllegalStateException("not initialized"))
|
||||
}
|
||||
val viewModel = settingsViewModel(repository = core)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.selectSection(SettingsSection.Storage)
|
||||
advanceUntilIdle()
|
||||
assertTrue(viewModel.state.value.storageLoadFailed)
|
||||
|
||||
core.storageUsageResult = Result.success(CoreStorageUsageModel(25UL, 10UL, 5UL, 0UL, 0UL))
|
||||
core.mutableState.value = core.mutableState.value.copy(isInitialized = true)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse(viewModel.state.value.storageLoadFailed)
|
||||
assertEquals(25UL, viewModel.state.value.storage?.transferCacheBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsDeleteAllTransfersImmediatelyClearsUnusedCache() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
@@ -533,10 +572,33 @@ class ViewModelsTest {
|
||||
assertEquals(null, viewModel.state.value.selectedFile)
|
||||
assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy)
|
||||
assertEquals(7UL, core.state.value.transfers.first().transferId)
|
||||
assertEquals(7UL, viewModel.state.value.selectedTransferId)
|
||||
assertEquals(TransferDetailPanel.Share, viewModel.state.value.detailPanel)
|
||||
assertContentEquals(thumbnail, previews.previews.value.getValue(7UL))
|
||||
assertEquals(listOf(selected), fileSystem.discardedPickedFiles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendViewModelStopsSharingFromCatalogAction() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val core = FakeCoreGateway().apply {
|
||||
mutableState.value = CoreState(isInitialized = true, transfers = listOf(sentTransfer(7UL)))
|
||||
}
|
||||
val viewModel = SendViewModel(
|
||||
core,
|
||||
FakeFileSystemService(folder),
|
||||
preferences(),
|
||||
FakeFilePreviewRepository(),
|
||||
UiMessageController(),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.stopSharing(7UL)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf(7UL), core.cancelledTransfers)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendComposerStaysOpenWhenShareCreationFails() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.vnidrop.app.notifications
|
||||
|
||||
import com.vnidrop.app.core.ReceiverDeliveryStatus
|
||||
import com.vnidrop.app.core.ReceiverRequestModel
|
||||
import com.vnidrop.app.core.ShareAccessPolicy
|
||||
import com.vnidrop.app.core.Transfer
|
||||
import com.vnidrop.app.core.TransferDirection
|
||||
import com.vnidrop.app.core.TransferStatus
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class TransferNotificationCoordinatorTest {
|
||||
@Test
|
||||
fun plansOnlyNewTerminalTransferOutcomes() {
|
||||
val transfers = listOf(
|
||||
transfer(1UL, TransferDirection.Receive, TransferStatus.Done),
|
||||
transfer(2UL, TransferDirection.Receive, TransferStatus.Failed),
|
||||
transfer(3UL, TransferDirection.Send, TransferStatus.Failed),
|
||||
transfer(4UL, TransferDirection.Send, TransferStatus.Sharing),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("receive-completed-1", "send-failed-3"),
|
||||
plannedTransferNotifications(transfers, setOf("receive-failed-2")).map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plansCompletedAndFailedReceiverOutcomesOnce() {
|
||||
val requests = listOf(
|
||||
request("completed", ReceiverDeliveryStatus.Completed),
|
||||
request("failed", ReceiverDeliveryStatus.Failed),
|
||||
request("accepted", ReceiverDeliveryStatus.Accepted),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("receiver-failed-failed"),
|
||||
plannedReceiverNotifications(requests, setOf("receiver-completed-completed")).map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
private fun transfer(id: ULong, direction: TransferDirection, status: TransferStatus) = Transfer(
|
||||
localId = "local-$id",
|
||||
transferId = id,
|
||||
direction = direction,
|
||||
status = status,
|
||||
peerId = null,
|
||||
transferName = "Transfer $id",
|
||||
contentHash = null,
|
||||
fileCount = 1UL,
|
||||
totalSize = 10UL,
|
||||
ticket = null,
|
||||
accessPolicy = ShareAccessPolicy.RequireApproval,
|
||||
createdAt = 1L,
|
||||
updatedAt = 1L,
|
||||
)
|
||||
|
||||
private fun request(id: String, status: ReceiverDeliveryStatus) = ReceiverRequestModel(
|
||||
id = id,
|
||||
transferId = 1UL,
|
||||
remoteEndpointId = "peer-$id",
|
||||
transferName = "Transfer",
|
||||
receiverName = "Receiver",
|
||||
receiverDeviceName = null,
|
||||
appVersion = "1.0",
|
||||
status = status,
|
||||
reason = null,
|
||||
requestedAt = 1L,
|
||||
respondedAt = 2L,
|
||||
completedAt = 3L,
|
||||
)
|
||||
}
|
||||
@@ -48,6 +48,9 @@ class FakeCoreGateway : CoreGateway {
|
||||
private var receiveGate: CompletableDeferred<Unit>? = null
|
||||
var deleteResult: Result<Unit> = Result.success(Unit)
|
||||
var clearTransferCacheResult: Result<ULong> = Result.success(0UL)
|
||||
var storageUsageResult: Result<CoreStorageUsageModel> = Result.success(
|
||||
CoreStorageUsageModel(0UL, 0UL, 0UL, 0UL, 0UL),
|
||||
)
|
||||
var clearReceiveHistoryResult: Result<ULong> = Result.success(0UL)
|
||||
val deletedTransfers = mutableListOf<ULong>()
|
||||
val cancelledTransfers = mutableListOf<ULong>()
|
||||
@@ -153,9 +156,7 @@ class FakeCoreGateway : CoreGateway {
|
||||
awaitReceiveIfNeeded()
|
||||
return receiveResult
|
||||
}
|
||||
override suspend fun storageUsage(): Result<CoreStorageUsageModel> = Result.success(
|
||||
CoreStorageUsageModel(0UL, 0UL, 0UL, 0UL, 0UL),
|
||||
)
|
||||
override suspend fun storageUsage(): Result<CoreStorageUsageModel> = storageUsageResult
|
||||
override suspend fun clearTransferCache(): Result<ULong> {
|
||||
clearTransferCacheCount += 1
|
||||
return clearTransferCacheResult
|
||||
@@ -248,6 +249,8 @@ class FakeFileSystemService(
|
||||
var supportsCustomFolders = true
|
||||
var effectiveFolder: ReceiveFolder? = null
|
||||
var canRevealFolder = false
|
||||
var reclaimedTemporaryBytes = 0UL
|
||||
var reclaimTemporaryStorageCount = 0
|
||||
var revealFolderResult: Result<Unit> = Result.success(Unit)
|
||||
val revealedFolders = mutableListOf<ReceiveFolder>()
|
||||
val discardedPickedFiles = mutableListOf<PickedShareFile>()
|
||||
@@ -259,6 +262,10 @@ class FakeFileSystemService(
|
||||
override suspend fun inspectReceivedArtifacts(artifacts: List<ReceivedArtifactModel>) =
|
||||
ReceivedStorageInspection(artifacts.fold(0UL) { total, item -> total + item.logicalSize }, artifacts.size, 0, 0)
|
||||
override suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong = 0UL
|
||||
override suspend fun reclaimTemporaryStorage(appDataDir: String, receiveFolder: ReceiveFolder): ULong {
|
||||
reclaimTemporaryStorageCount += 1
|
||||
return reclaimedTemporaryBytes
|
||||
}
|
||||
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = null
|
||||
override fun canRevealReceiveFolder(folder: ReceiveFolder) = canRevealFolder
|
||||
override suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> {
|
||||
|
||||
@@ -48,6 +48,9 @@ private class JvmFileSystemService : FileSystemService {
|
||||
return desktopTemporaryUsage(receiveFolder)
|
||||
}
|
||||
|
||||
override suspend fun reclaimTemporaryStorage(appDataDir: String, receiveFolder: ReceiveFolder): ULong =
|
||||
desktopReclaimTemporaryStorage(appDataDir, receiveFolder)
|
||||
|
||||
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = null
|
||||
|
||||
override suspend fun sharePickedFiles(
|
||||
@@ -88,3 +91,35 @@ internal fun desktopTemporaryUsage(receiveFolder: ReceiveFolder): ULong {
|
||||
}
|
||||
}.getOrDefault(0UL)
|
||||
}
|
||||
|
||||
internal fun desktopReclaimTemporaryStorage(appDataDir: String, receiveFolder: ReceiveFolder): ULong {
|
||||
var reclaimed = 0UL
|
||||
if (receiveFolder.kind == ReceiveFolderKind.FileSystemPath) {
|
||||
val receiveRoot = File(receiveFolder.value)
|
||||
receiveRoot.walkTopDown()
|
||||
.filter { file ->
|
||||
file.isFile &&
|
||||
file.name.startsWith(".") &&
|
||||
file.name.contains(".vnidrop-") &&
|
||||
file.name.endsWith(".part")
|
||||
}
|
||||
.toList()
|
||||
.forEach { file ->
|
||||
val size = file.length().coerceAtLeast(0L).toULong()
|
||||
if (file.delete()) reclaimed += size
|
||||
}
|
||||
}
|
||||
val appDataRoot = File(appDataDir)
|
||||
if (appDataRoot.isDirectory) {
|
||||
appDataRoot.walkTopDown()
|
||||
.filter { it.isDirectory && it.name == ".Trash" }
|
||||
.toList()
|
||||
.forEach { trash ->
|
||||
val size = trash.walkTopDown()
|
||||
.filter(File::isFile)
|
||||
.fold(0UL) { total, file -> total + file.length().coerceAtLeast(0L).toULong() }
|
||||
if (trash.deleteRecursively()) reclaimed += size
|
||||
}
|
||||
}
|
||||
return reclaimed
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import kotlin.io.path.createDirectories
|
||||
import kotlin.io.path.createTempDirectory
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class FileSystemServiceTest {
|
||||
@Test
|
||||
@@ -26,4 +28,32 @@ class FileSystemServiceTest {
|
||||
root.toFile().deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun desktopReclaimTemporaryStorageKeepsUserFiles() {
|
||||
val root = createTempDirectory("vnidrop-storage-cleanup")
|
||||
try {
|
||||
val receive = root.resolve("receive").createDirectories()
|
||||
val appData = root.resolve("app-data").createDirectories()
|
||||
val partial = receive.resolve(".photo.jpg.vnidrop-test.part")
|
||||
val received = receive.resolve("photo.jpg")
|
||||
val trash = appData.resolve("nested/.Trash").createDirectories()
|
||||
Files.write(partial, ByteArray(7))
|
||||
Files.write(received, ByteArray(13))
|
||||
Files.write(trash.resolve("stale.bin"), ByteArray(11))
|
||||
|
||||
assertEquals(
|
||||
18UL,
|
||||
desktopReclaimTemporaryStorage(
|
||||
appData.toString(),
|
||||
ReceiveFolder(ReceiveFolderKind.FileSystemPath, receive.toString(), "Test"),
|
||||
),
|
||||
)
|
||||
assertFalse(Files.exists(partial))
|
||||
assertFalse(Files.exists(trash))
|
||||
assertTrue(Files.exists(received))
|
||||
} finally {
|
||||
root.toFile().deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ class FoundationComposeTest {
|
||||
}
|
||||
}
|
||||
onNodeWithText("Notifications").performClick()
|
||||
onNodeWithText("Get notified about new receive requests while VniDrop is in the background.").assertIsDisplayed()
|
||||
onNodeWithText("Get notified about transfer activity while VniDrop is in the background.").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -212,10 +212,11 @@ class FoundationComposeTest {
|
||||
runOnIdle { assertTrue(cacheClearRequested) }
|
||||
|
||||
onNodeWithText("Delete all transfers").performClick()
|
||||
onNodeWithText(
|
||||
"This clears all sent and received transfer records from your history and immediately reclaims unused transfer cache. " +
|
||||
"Ongoing transfers and received files are not deleted. This can’t be undone.",
|
||||
).assertIsDisplayed()
|
||||
onNodeWithText(
|
||||
"This clears all sent and received transfer records from your history. Your received files are not deleted. " +
|
||||
"Cached shared content that is no longer needed is reclaimed automatically, which may take a little time. " +
|
||||
"This can’t be undone.",
|
||||
).assertIsDisplayed()
|
||||
runOnIdle { assertFalse(deleteRequested) }
|
||||
|
||||
onNodeWithTag("confirm-delete-all-transfers").performClick()
|
||||
@@ -636,10 +637,13 @@ class FoundationComposeTest {
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("Share").assertIsDisplayed()
|
||||
onNodeWithContentDescription("Share").assertIsDisplayed()
|
||||
onAllNodesWithText("Scan with VniDrop to receive this transfer").assertCountEquals(0)
|
||||
onNode(hasText("Share") and hasClickAction()).performClick()
|
||||
onNodeWithContentDescription("Share").performClick()
|
||||
runOnIdle { assertEquals(com.vnidrop.app.feature.send.TransferDetailPanel.Share, state.value.detailPanel) }
|
||||
waitUntil(timeoutMillis = 5_000) {
|
||||
onAllNodesWithText("Scan with VniDrop to receive this transfer").fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
onNodeWithText("Scan with VniDrop to receive this transfer").assertIsDisplayed()
|
||||
onNodeWithText("Save .vnd file").assertIsDisplayed()
|
||||
onNodeWithContentDescription("Close").assertIsDisplayed()
|
||||
|
||||
Reference in New Issue
Block a user