From c81cb7c8b60e0532a6805d9862e47c8c04ed16e8 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Fri, 24 Jul 2026 19:58:33 +0200 Subject: [PATCH] feat(shared): align non-Apple UX with Apple --- localization/strings.json | 44 ++-- .../app/core/FileSystemService.android.kt | 24 +++ .../composeResources/values-de/strings.xml | 11 + .../composeResources/values-es/strings.xml | 11 + .../composeResources/values-fr/strings.xml | 11 + .../composeResources/values-it/strings.xml | 11 + .../composeResources/values-nl/strings.xml | 11 + .../composeResources/values-pl/strings.xml | 11 + .../composeResources/values-pt/strings.xml | 11 + .../composeResources/values-ru/strings.xml | 11 + .../composeResources/values/strings.xml | 11 + .../commonMain/kotlin/com/vnidrop/app/App.kt | 38 ++++ .../kotlin/com/vnidrop/app/AppGraph.kt | 9 + .../com/vnidrop/app/core/FileSystemService.kt | 2 + .../app/feature/receive/ReceiveScreen.kt | 2 +- .../vnidrop/app/feature/send/SendCatalog.kt | 85 +++++++- .../com/vnidrop/app/feature/send/SendRoute.kt | 8 +- .../vnidrop/app/feature/send/SendScreen.kt | 12 +- .../vnidrop/app/feature/send/SendViewModel.kt | 28 ++- .../app/feature/send/TransferComposer.kt | 54 +++-- .../app/feature/send/TransferDetails.kt | 50 ++--- .../app/feature/settings/SettingsRoute.kt | 2 + .../app/feature/settings/SettingsScreen.kt | 10 + .../app/feature/settings/SettingsViewModel.kt | 47 ++++- .../app/feature/settings/StorageSettings.kt | 55 ++++- .../TransferNotificationCoordinator.kt | 190 ++++++++++++++++++ .../app/ui/feedback/UiMessageController.kt | 2 +- .../app/ui/feedback/VniDropSnackbarHost.kt | 2 +- .../com/vnidrop/app/feature/ViewModelsTest.kt | 62 ++++++ .../TransferNotificationCoordinatorTest.kt | 72 +++++++ .../kotlin/com/vnidrop/app/support/Fakes.kt | 13 +- .../vnidrop/app/core/FileSystemService.jvm.kt | 35 ++++ .../vnidrop/app/core/FileSystemServiceTest.kt | 30 +++ .../vnidrop/app/ui/FoundationComposeTest.kt | 18 +- 34 files changed, 902 insertions(+), 91 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/notifications/TransferNotificationCoordinator.kt create mode 100644 shared/src/commonTest/kotlin/com/vnidrop/app/notifications/TransferNotificationCoordinatorTest.kt diff --git a/localization/strings.json b/localization/strings.json index 9b0c084..57fdf8f 100644 --- a/localization/strings.json +++ b/localization/strings.json @@ -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", diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt index 9bb4d11..02d9d2c 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt @@ -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) diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index a1a464e..7ee3fb1 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -55,6 +55,7 @@ Was ist passiert? Genehmigen Zurück + Weitere Aktionen Abbrechen Abbrechen Dateien ändern @@ -122,6 +123,16 @@ Mitteilungen aktiviert. Mitteilungen erlauben Mitteilungen sind für VniDrop deaktiviert. Sie können sie in den Einstellungen aktivieren. + „%1$s“ wurde vollständig heruntergeladen. + Download abgeschlossen + „%1$s“ konnte nicht empfangen werden. + Download fehlgeschlagen + %1$s hat „%2$s“ vollständig empfangen. + Übertragung empfangen + %1$s konnte „%2$s“ nicht empfangen + Übertragung fehlgeschlagen + „%1$s“ konnte nicht geteilt werden. + Freigabe fehlgeschlagen Die Mitteilungseinstellungen konnten nicht geöffnet werden. Mitteilungen Mitteilungen sind auf diesem Gerät nicht verfügbar. diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index 2e3c80e..8d3ebfb 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -55,6 +55,7 @@ ¿Qué ocurrió? Aprobar Atrás + Más acciones Cancelar Cancelar Cambiar archivos @@ -122,6 +123,16 @@ Notificaciones activadas. Permitir notificaciones Las notificaciones están desactivadas para VniDrop. Puede activarlas en Ajustes. + «%1$s» terminó de descargarse. + Descarga completada + No se pudo recibir «%1$s». + Error en la descarga + %1$s terminó de recibir «%2$s». + Transferencia recibida + %1$s no pudo recibir «%2$s» + Error en la entrega + No se pudo compartir «%1$s». + Error al compartir No se pudieron abrir los ajustes de notificaciones. Notificaciones Las notificaciones no están disponibles en este dispositivo. diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index 1583d2d..ac3dc1a 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -55,6 +55,7 @@ Que s’est-il passé ? Approuver Retour + Plus d’actions Annuler Annuler Modifier les fichiers @@ -122,6 +123,16 @@ Notifications activées. Autoriser les notifications Les notifications sont désactivées pour VniDrop. Vous pouvez les activer dans les Réglages. + « %1$s » a fini de se télécharger. + Téléchargement terminé + « %1$s » n’a pas pu être reçu. + Échec du téléchargement + %1$s a fini de recevoir « %2$s ». + Transfert reçu + %1$s n\'a pas pu recevoir « %2$s » + Échec de l\'envoi + « %1$s » n’a pas pu être partagé. + Échec du partage Impossible d’ouvrir les réglages de notifications. Notifications Les notifications ne sont pas disponibles sur cet appareil. diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index af85d35..1ec70da 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -55,6 +55,7 @@ Cosa è accaduto? Approva Indietro + Altre azioni Annulla Annulla Cambia file @@ -122,6 +123,16 @@ Notifiche attivate. Consenti le notifiche Le notifiche sono disattivate per VniDrop. Può attivarle in Impostazioni. + «%1$s» è stato scaricato. + Download completato + Impossibile ricevere «%1$s». + Download non riuscito + %1$s ha finito di ricevere «%2$s». + Trasferimento ricevuto + %1$s non ha potuto ricevere “%2$s” + Consegna non riuscita + Impossibile condividere «%1$s». + Condivisione non riuscita Impossibile aprire le impostazioni delle notifiche. Notifiche Le notifiche non sono disponibili su questo dispositivo. diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index 93fe872..7033c9c 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -55,6 +55,7 @@ Wat is er gebeurd? Goedkeuren Terug + Meer acties Annuleren Annuleren Bestanden wijzigen @@ -122,6 +123,16 @@ Meldingen ingeschakeld. Meldingen toestaan Meldingen zijn uitgeschakeld voor VniDrop. U kunt ze inschakelen in Instellingen. + ‘%1$s’ is volledig gedownload. + Download voltooid + ‘%1$s’ kon niet worden ontvangen. + Download mislukt + %1$s heeft ‘%2$s’ volledig ontvangen. + Overdracht ontvangen + %1$s kon “%2$s” niet ontvangen + Levering mislukt + ‘%1$s’ kon niet worden gedeeld. + Delen mislukt De meldingsinstellingen konden niet worden geopend. Meldingen Meldingen zijn niet beschikbaar op dit apparaat. diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index aa4c09f..c91ac35 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -55,6 +55,7 @@ Co się stało? Zatwierdź Wstecz + Więcej działań Anuluj Anuluj Zmień pliki @@ -122,6 +123,16 @@ Powiadomienia włączone. Zezwól na powiadomienia Powiadomienia są wyłączone dla VniDrop. Możesz je włączyć w Ustawieniach. + Zakończono pobieranie „%1$s”. + Pobieranie zakończone + Nie udało się odebrać „%1$s”. + Pobieranie nie powiodło się + %1$s zakończył odbieranie „%2$s”. + Transfer odebrany + %1$s nie mógł odebrać „%2$s” + Dostarczenie nie powiodło się + Nie udało się udostępnić „%1$s”. + Udostępnianie nie powiodło się Nie udało się otworzyć ustawień powiadomień. Powiadomienia Powiadomienia nie są dostępne na tym urządzeniu. diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index 1c17822..78e529b 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -55,6 +55,7 @@ O que aconteceu? Aprovar Voltar + Mais ações Cancelar Cancelar Alterar ficheiros @@ -122,6 +123,16 @@ Notificações ativadas. Permitir notificações As notificações estão desativadas para o VniDrop. Pode ativá-las nas Definições. + «%1$s» concluiu a transferência. + Transferência concluída + Não foi possível receber «%1$s». + Falha na transferência + %1$s terminou de receber «%2$s». + Transferência recebida + %1$s não conseguiu receber “%2$s” + Falha na entrega + Não foi possível partilhar «%1$s». + Falha na partilha Não foi possível abrir as definições de notificações. Notificações As notificações não estão disponíveis neste dispositivo. diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index 08e9f83..64cec59 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -55,6 +55,7 @@ Что произошло? Одобрить Назад + Другие действия Отмена Отмена Изменить файлы @@ -122,6 +123,16 @@ Уведомления включены. Разрешить уведомления Уведомления отключены для VniDrop. Вы можете включить их в Настройках. + «%1$s» завершил загрузку. + Загрузка завершена + Не удалось получить «%1$s». + Ошибка загрузки + %1$s завершил получение «%2$s». + Передача получена + %1$s не удалось получить «%2$s» + Ошибка доставки + Не удалось поделиться «%1$s». + Не удалось поделиться Не удалось открыть настройки уведомлений. Уведомления Уведомления недоступны на этом устройстве. diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index ed6c6dd..dcf3929 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -55,6 +55,7 @@ What happened? Approve Back + More actions Cancel Cancel Change files @@ -122,6 +123,16 @@ Notifications enabled. Allow notifications Notifications are turned off for VniDrop. You can enable them in Settings. + “%1$s” finished downloading. + Download complete + “%1$s” couldn’t be received. + Download failed + %1$s finished receiving “%2$s”. + Transfer received + %1$s couldn\'t receive “%2$s” + Delivery failed + “%1$s” couldn’t be shared. + Sharing failed Could not open notification settings. Notifications Notifications are not available on this device. diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index 72ebbcc..260a3ce 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -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, + ) + } + } + } } } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt index f9054ec..6f64179 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt @@ -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) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt index 50355d4..a36a4c1 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt @@ -41,6 +41,8 @@ interface FileSystemService { suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus suspend fun inspectReceivedArtifacts(artifacts: List): 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 = diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt index e7f7dc0..0135444 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt index 278b9b9..3b2243a 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt @@ -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, + ) + }, + ) } } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt index f8d2cab..0eae335 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt @@ -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, ) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt index b46a171..065e3c9 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt @@ -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 = { _, _ -> }, 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() } 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, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt index 8cfc7a1..93b81a6 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt @@ -44,6 +44,7 @@ data class SendState( val receiversByTransfer: Map> = 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): String = when { files.isEmpty() -> "" files.size == 1 && files.first().isDirectory -> files.first().displayName diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt index 6ff956d..bb74a62 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt @@ -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( diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt index 422db5d..e7334d0 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt @@ -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(), + ) + } + } } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt index 16bbdd3..1f9fd18 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt @@ -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, ) } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt index 920f983..b303c13 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt @@ -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, ) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt index 1bde443..f8ea529 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt @@ -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) } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt index d5d48fc..682cf13 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/notifications/TransferNotificationCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/notifications/TransferNotificationCoordinator.kt new file mode 100644 index 0000000..992a69c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/notifications/TransferNotificationCoordinator.kt @@ -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, + published: Set, +): List = 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, + published: Set, +): List = 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() + 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) { + 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" + } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt index b77dfa8..fee8974 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt @@ -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 = emptyList()) : UiText data class Dynamic(val value: String) : UiText } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/VniDropSnackbarHost.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/VniDropSnackbarHost.kt index 7e8ce53..f5b3488 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/VniDropSnackbarHost.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/VniDropSnackbarHost.kt @@ -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()) } diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index 7d9535a..2cd18a5 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -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)) diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/notifications/TransferNotificationCoordinatorTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/notifications/TransferNotificationCoordinatorTest.kt new file mode 100644 index 0000000..66f7cbb --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/notifications/TransferNotificationCoordinatorTest.kt @@ -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, + ) +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt index 4e09b09..a912d6d 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -48,6 +48,9 @@ class FakeCoreGateway : CoreGateway { private var receiveGate: CompletableDeferred? = null var deleteResult: Result = Result.success(Unit) var clearTransferCacheResult: Result = Result.success(0UL) + var storageUsageResult: Result = Result.success( + CoreStorageUsageModel(0UL, 0UL, 0UL, 0UL, 0UL), + ) var clearReceiveHistoryResult: Result = Result.success(0UL) val deletedTransfers = mutableListOf() val cancelledTransfers = mutableListOf() @@ -153,9 +156,7 @@ class FakeCoreGateway : CoreGateway { awaitReceiveIfNeeded() return receiveResult } - override suspend fun storageUsage(): Result = Result.success( - CoreStorageUsageModel(0UL, 0UL, 0UL, 0UL, 0UL), - ) + override suspend fun storageUsage(): Result = storageUsageResult override suspend fun clearTransferCache(): Result { 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 = Result.success(Unit) val revealedFolders = mutableListOf() val discardedPickedFiles = mutableListOf() @@ -259,6 +262,10 @@ class FakeFileSystemService( override suspend fun inspectReceivedArtifacts(artifacts: List) = 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 { diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt index c566035..a6d17e1 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt @@ -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 +} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/core/FileSystemServiceTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/FileSystemServiceTest.kt index b8909f7..ae3a97d 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/core/FileSystemServiceTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/FileSystemServiceTest.kt @@ -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() + } + } } diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt index bbf7060..6ab14cf 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -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()