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