From 4074f4bee859d4b26c3d5de68712874ab3fa05e7 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Thu, 23 Jul 2026 22:22:30 +0200 Subject: [PATCH] feat(storage): clear inactive transfer cache --- crates/vnidrop/CORE_FLOW.md | 5 ++ crates/vnidrop/src/api.rs | 46 ++++++++++++- crates/vnidrop/src/lib.rs | 10 +-- crates/vnidrop/tests/storage.rs | 26 +++++++ .../app/core/FileSystemService.android.kt | 2 +- .../composeResources/values-de/strings.xml | 10 ++- .../composeResources/values-es/strings.xml | 10 ++- .../composeResources/values-fr/strings.xml | 10 ++- .../composeResources/values-it/strings.xml | 10 ++- .../composeResources/values-nl/strings.xml | 10 ++- .../composeResources/values-pl/strings.xml | 10 ++- .../composeResources/values-pt/strings.xml | 10 ++- .../composeResources/values-ru/strings.xml | 10 ++- .../composeResources/values/strings.xml | 10 ++- .../kotlin/com/vnidrop/app/core/CoreModels.kt | 1 + .../com/vnidrop/app/core/CoreRepository.kt | 28 ++++++++ .../com/vnidrop/app/core/FileSystemService.kt | 2 +- .../app/feature/settings/SettingsRoute.kt | 1 + .../app/feature/settings/SettingsScreen.kt | 13 +++- .../app/feature/settings/SettingsViewModel.kt | 41 ++++++++++-- .../app/feature/settings/StorageSettings.kt | 67 ++++++++++++++++++- .../com/vnidrop/app/feature/ViewModelsTest.kt | 51 ++++++++++++++ .../kotlin/com/vnidrop/app/support/Fakes.kt | 8 ++- .../vnidrop/app/core/FileSystemService.jvm.kt | 25 ++++++- .../app/core/CoreRepositoryStorageTest.kt | 43 ++++++++++++ .../vnidrop/app/core/FileSystemServiceTest.kt | 29 ++++++++ .../vnidrop/app/ui/FoundationComposeTest.kt | 16 ++++- 27 files changed, 457 insertions(+), 47 deletions(-) create mode 100644 crates/vnidrop/tests/storage.rs create mode 100644 shared/src/jvmTest/kotlin/com/vnidrop/app/core/CoreRepositoryStorageTest.kt create mode 100644 shared/src/jvmTest/kotlin/com/vnidrop/app/core/FileSystemServiceTest.kt diff --git a/crates/vnidrop/CORE_FLOW.md b/crates/vnidrop/CORE_FLOW.md index 6fa08e9..f0d1fa4 100644 --- a/crates/vnidrop/CORE_FLOW.md +++ b/crates/vnidrop/CORE_FLOW.md @@ -91,6 +91,11 @@ or temporary tag. Receive downloads keep a temporary tag through export and beco reclaimable after publication. Restart reconciliation repairs active-share tags, removes orphan share tags, and never restores a stopped share. +The explicit transfer-cache action is available only when no transfer or share is +active. It shuts the core down cleanly, removes the app-owned blob store, and then +restarts the core with the same identity and network configuration. Deleting all +transfer records invokes the same cleanup after live shares have been stopped. + ## Resource Limits `CoreLimits` controls source count, collection files and bytes, path and ticket diff --git a/crates/vnidrop/src/api.rs b/crates/vnidrop/src/api.rs index cfb3f50..e387075 100644 --- a/crates/vnidrop/src/api.rs +++ b/crates/vnidrop/src/api.rs @@ -2,8 +2,9 @@ use anyhow::Context; use iroh::RelayUrl; use iroh_blobs::Hash; use serde::{Deserialize, Serialize}; -use std::{collections::BTreeSet, net::IpAddr, str::FromStr}; +use std::{collections::BTreeSet, net::IpAddr, path::PathBuf, str::FromStr}; +use crate::error::VnidropError; use crate::util::{non_empty, now_ms}; pub(crate) const MAX_CUSTOM_RELAYS: usize = 8; @@ -126,6 +127,49 @@ pub fn default_core_network_config() -> CoreNetworkConfig { CoreNetworkConfig::default() } +/// Removes the blob store after its owning core has shut down. +/// +/// Callers must verify that no transfer or share is active before shutdown. +#[uniffi::export] +pub fn clear_inactive_transfer_cache(app_data_dir: String) -> Result { + let result = (|| -> anyhow::Result { + let app_data_dir = PathBuf::from(app_data_dir); + anyhow::ensure!( + app_data_dir.is_absolute(), + "app data directory must be absolute" + ); + anyhow::ensure!( + app_data_dir.file_name().is_some(), + "app data directory must not be a filesystem root" + ); + let blobs = app_data_dir.join("blobs"); + let metadata = match std::fs::symlink_metadata(&blobs) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(error.into()), + }; + anyhow::ensure!( + metadata.is_dir() && !metadata.file_type().is_symlink(), + "blob store path is not a directory" + ); + let bytes = walkdir::WalkDir::new(&blobs) + .follow_links(false) + .into_iter() + .try_fold(0u64, |total, entry| { + let entry = entry?; + let metadata = entry.metadata()?; + Ok::<_, walkdir::Error>(if metadata.is_file() { + total.saturating_add(metadata.len()) + } else { + total + }) + })?; + std::fs::remove_dir_all(blobs)?; + Ok(bytes) + })(); + result.map_err(VnidropError::filesystem) +} + #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct CoreLimits { pub max_sources: u64, diff --git a/crates/vnidrop/src/lib.rs b/crates/vnidrop/src/lib.rs index 6028dd7..1ed14d8 100644 --- a/crates/vnidrop/src/lib.rs +++ b/crates/vnidrop/src/lib.rs @@ -14,11 +14,11 @@ mod transfer_state; mod util; pub use api::{ - default_core_limits, default_core_network_config, CoreEvent, CoreEventSink, CoreLimits, - CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, PublishedOutput, ReceiveOutputSink, - ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, - ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, TicketInspection, - TransferAccessMode, TransferMetadata, + clear_inactive_transfer_cache, default_core_limits, default_core_network_config, CoreEvent, + CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, PublishedOutput, + ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, + RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, + TicketInspection, TransferAccessMode, TransferMetadata, }; pub use error::VnidropError; pub use runtime::VnidropCore; diff --git a/crates/vnidrop/tests/storage.rs b/crates/vnidrop/tests/storage.rs new file mode 100644 index 0000000..248151b --- /dev/null +++ b/crates/vnidrop/tests/storage.rs @@ -0,0 +1,26 @@ +use vnidrop::clear_inactive_transfer_cache; + +#[test] +fn inactive_transfer_cache_is_removed_and_reports_reclaimed_bytes() { + let app_data = tempfile::tempdir().unwrap(); + let blobs = app_data.path().join("blobs"); + std::fs::create_dir_all(blobs.join("data")).unwrap(); + std::fs::write(blobs.join("data").join("payload"), vec![9u8; 4096]).unwrap(); + std::fs::write(blobs.join("blobs.db"), vec![3u8; 512]).unwrap(); + + let reclaimed = + clear_inactive_transfer_cache(app_data.path().to_string_lossy().into_owned()).unwrap(); + + assert_eq!(reclaimed, 4608); + assert!(!blobs.exists()); +} + +#[test] +fn inactive_transfer_cache_rejects_relative_app_data_paths() { + assert!(clear_inactive_transfer_cache("relative/path".to_string()).is_err()); +} + +#[test] +fn inactive_transfer_cache_rejects_filesystem_roots() { + assert!(clear_inactive_transfer_cache(std::path::MAIN_SEPARATOR_STR.to_string()).is_err()); +} 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 fb8bc5b..9bb4d11 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 @@ -103,7 +103,7 @@ private class AndroidFileSystemService( return ReceivedStorageInspection(bytes, existing, missing, inaccessible) } - override suspend fun temporaryUsage(): ULong = directorySize(context.cacheDir) + override suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong = directorySize(context.cacheDir) override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = when (folder.kind) { diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index a4b86a1..88f4974 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -231,16 +231,20 @@ Wird empfangen Beendet Wird berechnet… + Übertragungscache leeren + Entfernt zwischengespeicherte Übertragungsinhalte, die nicht von einem laufenden Empfang oder einer aktiven Freigabe verwendet werden. Empfangene Dateien und der Übertragungsverlauf werden nicht gelöscht. + Cache wird geleert… Alle Übertragungen löschen - Dadurch werden alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf gelöscht. Ihre empfangenen Dateien werden nicht gelöscht. Nicht mehr benötigte zwischengespeicherte freigegebene Inhalte werden automatisch bereinigt; dies kann etwas dauern. Dies kann nicht rückgängig gemacht werden. + Dadurch werden alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf gelöscht und nicht benötigter Übertragungscache sofort freigegeben. Laufende Übertragungen und empfangene Dateien werden nicht gelöscht. Dies kann nicht rückgängig gemacht werden. App-Daten Wird gelöscht… - Übertragungsdaten umfassen Ihren Verlauf und zwischengespeicherte Inhalte aktiver Freigaben. Nicht mehr benötigter Cache wird nach dem Löschen der Datensätze automatisch bereinigt; dies kann etwas dauern. Empfangene Dateien werden für diese Übersicht erfasst, aber hier niemals gelöscht. + Der Übertragungscache enthält heruntergeladene oder importierte Inhalte, einschließlich Daten, die durch laufende Empfänge und aktive Freigaben geschützt sind. Beim Leeren wird nur nicht benötigter Cache entfernt. Empfangene Dateien werden hier niemals gelöscht. Empfangene Dateien Temporäre Dateien Speicher Gesamt - Übertragungsdaten + Übertragungscache + Übertragungscache geleert Alle Übertragungen gelöscht Wichtige Aktualisierungen zu dieser Übertragung ansehen Aktivität diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index 826cf85..cadea0e 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -231,16 +231,20 @@ Recibiendo Detenido Calculando… + Borrar caché de transferencias + Elimina el contenido de transferencia en caché que no esté siendo utilizado por una recepción en curso o un recurso compartido activo. Los archivos recibidos y el historial no se eliminan. + Borrando caché… Eliminar todas las transferencias - Esto borra de su historial todos los registros de transferencias enviadas y recibidas. Sus archivos recibidos no se eliminan. El contenido compartido en caché que ya no se necesita se recupera automáticamente, lo que puede tardar un poco. Esto no se puede deshacer. + Esto borra del historial todos los registros de transferencias enviadas y recibidas y libera inmediatamente la caché de transferencia no utilizada. Las transferencias en curso y los archivos recibidos no se eliminan. Esto no se puede deshacer. Datos de la aplicación Eliminando… - Los datos de transferencia incluyen su historial y el contenido en caché de los recursos compartidos activos. La caché innecesaria se recupera automáticamente tras eliminar los registros, lo que puede tardar un poco. Los archivos recibidos se registran para este resumen, pero nunca se eliminan aquí. + La caché de transferencias incluye contenido descargado o importado, incluidos los datos protegidos por recepciones en curso y recursos compartidos activos. Al borrarla solo se elimina la caché no utilizada. Los archivos recibidos nunca se eliminan aquí. Archivos recibidos Archivos temporales Almacenamiento Total - Datos de transferencia + Caché de transferencias + Caché de transferencias borrada Todas las transferencias eliminadas Vea las actualizaciones importantes de esta transferencia Actividad diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index 2d646c6..ec03364 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -231,16 +231,20 @@ Réception Arrêté Calcul… + Vider le cache des transferts + Supprime le contenu de transfert en cache qui n’est pas utilisé par une réception en cours ou un partage actif. Les fichiers reçus et l’historique ne sont pas supprimés. + Vidage du cache… Supprimer tous les transferts - Cela efface de votre historique tous les enregistrements de transferts envoyés et reçus. Vos fichiers reçus ne sont pas supprimés. Le contenu partagé mis en cache qui n’est plus nécessaire est récupéré automatiquement, ce qui peut prendre un peu de temps. Cette action est irréversible. + Cela efface tous les transferts envoyés et reçus de l’historique et libère immédiatement le cache inutilisé. Les transferts en cours et les fichiers reçus ne sont pas supprimés. Cette action est irréversible. Données de l’app Suppression… - Les données de transfert comprennent votre historique et le contenu mis en cache pour les partages actifs. Le cache inutile est récupéré automatiquement après la suppression des enregistrements, ce qui peut prendre un peu de temps. Les fichiers reçus sont suivis pour ce récapitulatif, mais ne sont jamais supprimés ici. + Le cache des transferts comprend le contenu téléchargé ou importé, y compris les données protégées par les réceptions en cours et les partages actifs. Le vider ne supprime que le cache inutilisé. Les fichiers reçus ne sont jamais supprimés ici. Fichiers reçus Fichiers temporaires Stockage Total - Données de transfert + Cache des transferts + Cache des transferts vidé Tous les transferts supprimés Consultez les mises à jour importantes de ce transfert Activité diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index 4d41143..ec630da 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -231,16 +231,20 @@ Ricezione Interrotto Calcolo… + Svuota cache trasferimenti + Rimuove il contenuto dei trasferimenti memorizzato nella cache che non è usato da una ricezione in corso o da una condivisione attiva. I file ricevuti e la cronologia non vengono eliminati. + Svuotamento cache… Elimina tutti i trasferimenti - Questo cancella dalla cronologia tutti i record dei trasferimenti inviati e ricevuti. I file ricevuti non vengono eliminati. Il contenuto condiviso nella cache che non serve più viene recuperato automaticamente, operazione che può richiedere un po’ di tempo. Questa azione non può essere annullata. + Elimina dalla cronologia tutti i trasferimenti inviati e ricevuti e libera immediatamente la cache inutilizzata. I trasferimenti in corso e i file ricevuti non vengono eliminati. Questa azione non può essere annullata. Dati dell’app Eliminazione… - I dati di trasferimento includono la cronologia e il contenuto nella cache per le condivisioni attive. La cache non necessaria viene recuperata automaticamente dopo la rimozione dei record, operazione che può richiedere un po’ di tempo. I file ricevuti vengono monitorati per questo riepilogo, ma non sono mai eliminati qui. + La cache dei trasferimenti include contenuti scaricati o importati, compresi i dati protetti da ricezioni in corso e condivisioni attive. Lo svuotamento rimuove solo la cache inutilizzata. I file ricevuti non vengono mai eliminati qui. File ricevuti File temporanei Archiviazione Totale - Dati di trasferimento + Cache trasferimenti + Cache trasferimenti svuotata Tutti i trasferimenti eliminati Veda gli aggiornamenti importanti di questo trasferimento Attività diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index 5632874..c7c35fe 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -231,16 +231,20 @@ Ontvangen Gestopt Berekenen… + Overdrachtscache wissen + Verwijdert overdrachtsinhoud uit de cache die niet wordt gebruikt door een lopende ontvangst of actieve share. Ontvangen bestanden en de overdrachtsgeschiedenis worden niet verwijderd. + Cache wissen… Alle overdrachten verwijderen - Hiermee worden alle records van verzonden en ontvangen overdrachten uit uw geschiedenis gewist. Uw ontvangen bestanden worden niet verwijderd. Gedeelde inhoud in de cache die niet meer nodig is, wordt automatisch opgeruimd; dit kan enige tijd duren. Dit kan niet ongedaan worden gemaakt. + Hiermee worden alle verzonden en ontvangen overdrachten uit de geschiedenis gewist en wordt ongebruikte overdrachtscache direct vrijgemaakt. Lopende overdrachten en ontvangen bestanden worden niet verwijderd. Dit kan niet ongedaan worden gemaakt. Appgegevens Verwijderen… - Overdrachtsgegevens omvatten uw geschiedenis en inhoud in de cache voor actieve shares. Onnodige cache wordt automatisch opgeruimd nadat overdrachtsrecords zijn verwijderd; dit kan enige tijd duren. Ontvangen bestanden worden voor dit overzicht bijgehouden, maar hier nooit verwijderd. + De overdrachtscache bevat gedownloade of geïmporteerde inhoud, inclusief gegevens die door lopende ontvangsten en actieve shares worden beschermd. Wissen verwijdert alleen ongebruikte cache. Ontvangen bestanden worden hier nooit verwijderd. Ontvangen bestanden Tijdelijke bestanden Opslag Totaal - Overdrachtsgegevens + Overdrachtscache + Overdrachtscache gewist Alle overdrachten verwijderd Bekijk belangrijke updates voor deze overdracht Activiteit diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index 5d5c8f4..0ef6773 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -231,16 +231,20 @@ Odbieranie Zatrzymany Obliczanie… + Wyczyść pamięć podręczną transferów + Usuwa zawartość transferów z pamięci podręcznej, która nie jest używana przez trwające odbieranie ani aktywne udostępnianie. Odebrane pliki i historia nie są usuwane. + Czyszczenie pamięci podręcznej… Usuń wszystkie transfery - Spowoduje to usunięcie z historii wszystkich rekordów wysłanych i odebranych transferów. Odebrane pliki nie zostaną usunięte. Niepotrzebna już zawartość udostępniona w pamięci podręcznej jest odzyskiwana automatycznie, co może chwilę potrwać. Tej operacji nie można cofnąć. + Usuwa z historii wszystkie wysłane i odebrane transfery oraz natychmiast zwalnia nieużywaną pamięć podręczną. Trwające transfery i odebrane pliki nie są usuwane. Tej operacji nie można cofnąć. Dane aplikacji Usuwanie… - Dane transferu obejmują historię oraz zawartość w pamięci podręcznej dla aktywnych udostępnień. Niepotrzebna pamięć podręczna jest odzyskiwana automatycznie po usunięciu rekordów, co może chwilę potrwać. Odebrane pliki są śledzone na potrzeby tego podsumowania, ale nigdy nie są tu usuwane. + Pamięć podręczna transferów zawiera pobraną lub zaimportowaną zawartość, w tym dane chronione przez trwające odbieranie i aktywne udostępnianie. Czyszczenie usuwa tylko nieużywaną pamięć podręczną. Odebrane pliki nigdy nie są tutaj usuwane. Odebrane pliki Pliki tymczasowe Pamięć Łącznie - Dane transferu + Pamięć podręczna transferów + Wyczyszczono pamięć podręczną transferów Usunięto wszystkie transfery Zobacz ważne aktualizacje tego transferu Aktywność diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index 26e59da..7b7851f 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -231,16 +231,20 @@ A receber Parada A calcular… + Limpar cache de transferências + Remove conteúdo de transferência em cache que não esteja a ser utilizado por uma receção em curso ou partilha ativa. Os ficheiros recebidos e o histórico não são eliminados. + A limpar cache… Eliminar todas as transferências - Isto elimina do histórico todos os registos de transferências enviadas e recebidas. Os ficheiros recebidos não são eliminados. O conteúdo partilhado em cache que já não é necessário é recuperado automaticamente, o que pode demorar algum tempo. Esta ação não pode ser anulada. + Isto elimina do histórico todas as transferências enviadas e recebidas e liberta imediatamente a cache não utilizada. As transferências em curso e os ficheiros recebidos não são eliminados. Esta ação não pode ser anulada. Dados da aplicação A eliminar… - Os dados de transferência incluem o histórico e o conteúdo em cache das partilhas ativas. A cache desnecessária é recuperada automaticamente após a remoção dos registos, o que pode demorar algum tempo. Os ficheiros recebidos são acompanhados para este resumo, mas nunca são eliminados aqui. + A cache de transferências inclui conteúdo descarregado ou importado, incluindo dados protegidos por receções em curso e partilhas ativas. A limpeza remove apenas a cache não utilizada. Os ficheiros recebidos nunca são eliminados aqui. Ficheiros recebidos Ficheiros temporários Armazenamento Total - Dados de transferência + Cache de transferências + Cache de transferências limpa Todas as transferências eliminadas Ver as atualizações importantes desta transferência Atividade diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index 4fac253..f05064c 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -231,16 +231,20 @@ Получение Остановлено Вычисление… + Очистить кэш передач + Удаляет кэшированное содержимое передач, которое не используется текущим приёмом или активной раздачей. Полученные файлы и история передач не удаляются. + Очистка кэша… Удалить все передачи - Это удалит из истории все записи об отправленных и полученных передачах. Полученные файлы не удаляются. Кэшированное общее содержимое, которое больше не требуется, освобождается автоматически; это может занять некоторое время. Это действие нельзя отменить. + Это удалит из истории все отправленные и полученные передачи и немедленно освободит неиспользуемый кэш. Текущие передачи и полученные файлы не удаляются. Это действие нельзя отменить. Данные приложения Удаление… - Данные передачи включают историю и кэшированное содержимое активных раздач. Ненужный кэш освобождается автоматически после удаления записей; это может занять некоторое время. Полученные файлы учитываются в этой сводке, но никогда не удаляются здесь. + Кэш передач содержит загруженные или импортированные данные, включая содержимое, защищённое текущими приёмами и активными раздачами. Очистка удаляет только неиспользуемый кэш. Полученные файлы здесь никогда не удаляются. Полученные файлы Временные файлы Хранилище Всего - Данные передачи + Кэш передач + Кэш передач очищен Все передачи удалены Просматривайте важные обновления этой передачи Активность diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 5cc6635..573617d 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -231,16 +231,20 @@ Receiving Stopped Calculating… + Clear transfer cache + Removes cached transfer content after briefly restarting VniDrop. Finish ongoing transfers and stop active shares first. Received files and transfer history are not deleted. + Clearing cache… Delete all transfers - 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. + 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. App data Deleting… - Transfer data includes your history and cached content for active shares. Unneeded cache is reclaimed automatically after transfer records are removed, which may take a little time. Received files are tracked for this summary but are never deleted here. + Transfer cache includes downloaded or imported content used by transfers and shares. It can be cleared when no transfer or share is active. Received files are tracked for this summary but are never deleted here. Received files Temporary files Storage Total - Transfer data + Transfer cache + Transfer cache cleared All transfers deleted See important updates for this transfer Activity diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt index c6dac49..f3c7768 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt @@ -182,6 +182,7 @@ interface CoreGateway { suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result suspend fun receiveWithOutputSinkV2(ticket: String, outputSink: ReceiveOutputSinkV2, receiverName: String): Result suspend fun storageUsage(): Result + suspend fun clearTransferCache(): Result suspend fun receivedArtifacts(): Result> suspend fun cancel(transferId: ULong): Result suspend fun delete(transferId: ULong): Result diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index 0694237..a467f48 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -30,6 +30,7 @@ import uniffi.vnidrop.TicketInspection import uniffi.vnidrop.TransferMetadata import uniffi.vnidrop.TransferAccessMode import uniffi.vnidrop.VnidropCore +import uniffi.vnidrop.clearInactiveTransferCache import uniffi.vnidrop.defaultCoreNetworkConfig class CoreRepository( @@ -45,6 +46,8 @@ class CoreRepository( override val signals: SharedFlow = _signals.asSharedFlow() private var core: VnidropCore? = null + private var currentAppDataDir: String? = null + private var currentRelaySettings = RelaySettings() private val lifecycleGate = CoreLifecycleGate() private val sink = object : CoreEventSink { @@ -77,6 +80,8 @@ class CoreRepository( previousCore?.shutdown() core = null core = VnidropCore.initializeWithNetworkConfig(appDataDir, sink, relaySettings.toNative()) + currentAppDataDir = appDataDir + currentRelaySettings = relaySettings refreshSnapshot(requireCore()) _state.update { it.copy(isInitialized = true) } } @@ -84,6 +89,7 @@ class CoreRepository( override fun shutdown() { core?.shutdown() core = null + currentAppDataDir = null _state.value = CoreState() } @@ -168,6 +174,28 @@ class CoreRepository( ) } + override suspend fun clearTransferCache(): Result = runReconfiguration { + val activeCore = requireCore() + val status = activeCore.status() + require(status.activeTransfers == 0UL && status.activeShares == 0UL) { + "Wait for active transfers and shares to finish before clearing the transfer cache" + } + val appDataDir = requireNotNull(currentAppDataDir) { "Core data directory is unavailable" } + val relaySettings = currentRelaySettings + _state.update { it.copy(isInitialized = false, status = null) } + activeCore.shutdown() + core = null + var reclaimed = 0UL + try { + reclaimed = clearInactiveTransferCache(appDataDir) + } finally { + core = VnidropCore.initializeWithNetworkConfig(appDataDir, sink, relaySettings.toNative()) + refreshSnapshot(requireCore()) + _state.update { it.copy(isInitialized = true) } + } + reclaimed + } + override suspend fun receivedArtifacts(): Result> = runCore { activeCore -> activeCore.listReceivedArtifacts().map { artifact -> ReceivedArtifactModel( 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 537f0b6..50355d4 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt @@ -40,7 +40,7 @@ interface FileSystemService { if (supportsCustomReceiveFolders) configuredFolder else defaultReceiveFolder() suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus suspend fun inspectReceivedArtifacts(artifacts: List): ReceivedStorageInspection - suspend fun temporaryUsage(): ULong + suspend fun temporaryUsage(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/settings/SettingsRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt index e9ec600..16bbdd3 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 @@ -41,5 +41,6 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) { onBugIncludeLogsChanged = viewModel::setBugIncludeLogs, onSubmitBugReport = viewModel::submitBugReport, onDeleteAllTransfers = viewModel::deleteAllTransfers, + onClearTransferCache = viewModel::clearTransferCache, ) } 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 dbb29b6..920f983 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 @@ -31,6 +31,7 @@ fun SettingsScreen( onBugIncludeLogsChanged: (Boolean) -> Unit, onSubmitBugReport: () -> Unit, onDeleteAllTransfers: () -> Unit = {}, + onClearTransferCache: () -> Unit = {}, onRelayModeChanged: (RelayMode) -> Unit = {}, onRelayUrlChanged: (Int, String) -> Unit = { _, _ -> }, onAddRelayUrl: () -> Unit = {}, @@ -67,6 +68,7 @@ fun SettingsScreen( onBugIncludeLogsChanged = onBugIncludeLogsChanged, onSubmitBugReport = onSubmitBugReport, onDeleteAllTransfers = onDeleteAllTransfers, + onClearTransferCache = onClearTransferCache, onRelayModeChanged = onRelayModeChanged, onRelayUrlChanged = onRelayUrlChanged, onAddRelayUrl = onAddRelayUrl, @@ -107,6 +109,7 @@ fun SettingsScreen( onBugIncludeLogsChanged = onBugIncludeLogsChanged, onSubmitBugReport = onSubmitBugReport, onDeleteAllTransfers = onDeleteAllTransfers, + onClearTransferCache = onClearTransferCache, onRelayModeChanged = onRelayModeChanged, onRelayUrlChanged = onRelayUrlChanged, onAddRelayUrl = onAddRelayUrl, @@ -139,6 +142,7 @@ private fun SettingsSectionContent( onBugIncludeLogsChanged: (Boolean) -> Unit, onSubmitBugReport: () -> Unit, onDeleteAllTransfers: () -> Unit, + onClearTransferCache: () -> Unit, onRelayModeChanged: (RelayMode) -> Unit, onRelayUrlChanged: (Int, String) -> Unit, onAddRelayUrl: () -> Unit, @@ -160,7 +164,14 @@ private fun SettingsSectionContent( showBack = showBack, ) SettingsSection.Notifications -> NotificationSettings(state, onNotificationsChanged, onOpenNotificationSettings, onBack, showBack) - SettingsSection.Storage -> StorageSettings(state, windowClass, onDeleteAllTransfers, onBack, showBack) + SettingsSection.Storage -> StorageSettings( + state, + windowClass, + onDeleteAllTransfers, + onClearTransferCache, + onBack, + showBack, + ) SettingsSection.About -> AboutSettings( state = state, onDiagnosticsChanged = onDiagnosticsChanged, 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 48a96eb..1bde443 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 @@ -49,6 +49,8 @@ import vnidrop.shared.generated.resources.notifications_permission_denied import vnidrop.shared.generated.resources.notifications_settings_open_failed 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 enum class SettingsSection { Overview, @@ -111,6 +113,7 @@ data class SettingsState( val storage: StorageBreakdown? = null, val isCalculatingStorage: Boolean = false, val isDeletingTransfers: Boolean = false, + val isClearingTransferCache: Boolean = false, ) { val hasRelaySettingsChanges: Boolean get() = relayMode != savedRelaySettings.mode || @@ -208,6 +211,7 @@ class SettingsViewModel( viewModelScope.launch { _state.update { it.copy(isCalculatingStorage = true) } try { + val receiveFolder = _state.value.receiveFolder ?: fileSystemService.defaultReceiveFolder() val coreUsage = repository.storageUsage().getOrThrow() val artifacts = repository.receivedArtifacts().getOrThrow() val received = fileSystemService.inspectReceivedArtifacts(artifacts) @@ -216,7 +220,7 @@ class SettingsViewModel( storage = StorageBreakdown( transferCacheBytes = coreUsage.blobStoreBytes, appDataBytes = coreUsage.appDataBytes, - temporaryBytes = fileSystemService.temporaryUsage(), + temporaryBytes = fileSystemService.temporaryUsage(receiveFolder), receivedBytes = received.existingBytes, receivedFileCount = received.existingCount, missingReceivedFileCount = received.missingCount, @@ -235,18 +239,45 @@ class SettingsViewModel( } fun deleteAllTransfers() { - if (_state.value.isDeletingTransfers) return + if (_state.value.isDeletingTransfers || _state.value.isClearingTransferCache) return viewModelScope.launch { _state.update { it.copy(isDeletingTransfers = true) } val failures = repository.state.value.transfers .map { repository.delete(it.transferId) } .count { it.isFailure } + val cleanup = repository.clearTransferCache() _state.update { it.copy(isDeletingTransfers = false) } - if (failures == 0) { - loadStorageUsage() - } else { + loadStorageUsage() + if (failures > 0) { messages.error(IllegalStateException("Could not delete $failures transfer records")) + } else if (cleanup.isSuccess) { + messages.tryShow(UiMessage(UiText.Resource(Res.string.storage_transfers_deleted), UiMessageTone.Success)) } + cleanup.onFailure(messages::error) + } + } + + fun clearTransferCache() { + if ( + _state.value.isDeletingTransfers || + _state.value.isClearingTransferCache || + _state.value.hasActiveNetworkWork + ) return + viewModelScope.launch { + _state.update { it.copy(isClearingTransferCache = true) } + repository.clearTransferCache().fold( + onSuccess = { + _state.update { it.copy(isClearingTransferCache = false) } + loadStorageUsage() + messages.tryShow( + UiMessage(UiText.Resource(Res.string.storage_transfer_cache_cleared), UiMessageTone.Success), + ) + }, + onFailure = { error -> + _state.update { it.copy(isClearingTransferCache = 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 7011977..d5d48fc 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 @@ -33,6 +33,9 @@ import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.button_cancel import vnidrop.shared.generated.resources.storage_app_data 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_delete_transfers import vnidrop.shared.generated.resources.storage_delete_transfers_description import vnidrop.shared.generated.resources.storage_deleting @@ -48,10 +51,12 @@ internal fun StorageSettings( state: SettingsState, windowClass: WindowClass, onDeleteAllTransfers: () -> Unit, + onClearTransferCache: () -> Unit, onBack: () -> Unit, showBack: Boolean, ) { var showDeleteConfirmation by rememberSaveable { mutableStateOf(false) } + var showClearCacheConfirmation by rememberSaveable { mutableStateOf(false) } Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { SettingsTopBar(stringResource(Res.string.storage_title), onBack, showBack) val storage = state.storage @@ -79,9 +84,25 @@ internal fun StorageSettings( ) } } + SecondaryButton( + text = stringResource( + if (state.isClearingTransferCache) { + Res.string.storage_clearing_transfer_cache + } else { + Res.string.storage_clear_transfer_cache + }, + ), + onClick = { showClearCacheConfirmation = true }, + enabled = !state.isDeletingTransfers && + !state.isClearingTransferCache && + !state.isCalculatingStorage && + !state.hasActiveNetworkWork, + ) Button( onClick = { showDeleteConfirmation = true }, - enabled = !state.isDeletingTransfers, + enabled = !state.isDeletingTransfers && + !state.isClearingTransferCache && + !state.isCalculatingStorage, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), ) { Text(stringResource(if (state.isDeletingTransfers) Res.string.storage_deleting else Res.string.storage_delete_transfers)) @@ -92,6 +113,20 @@ internal fun StorageSettings( color = LocalVniDropColors.current.foregroundLighter, ) } + if (showClearCacheConfirmation) { + AdaptiveDrawer( + windowClass = windowClass, + onDismissRequest = { showClearCacheConfirmation = false }, + ) { + ClearTransferCachePanel( + onCancel = { showClearCacheConfirmation = false }, + onConfirm = { + showClearCacheConfirmation = false + onClearTransferCache() + }, + ) + } + } if (showDeleteConfirmation) { AdaptiveDrawer( windowClass = windowClass, @@ -108,6 +143,36 @@ internal fun StorageSettings( } } +@Composable +private fun ClearTransferCachePanel( + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + Column( + Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + stringResource(Res.string.storage_clear_transfer_cache), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + stringResource(Res.string.storage_clear_transfer_cache_description), + color = LocalVniDropColors.current.foregroundLighter, + style = MaterialTheme.typography.bodyMedium, + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)) { + SecondaryButton(stringResource(Res.string.button_cancel), onClick = onCancel) + DestructiveButton( + stringResource(Res.string.storage_clear_transfer_cache), + onClick = onConfirm, + modifier = Modifier.testTag("confirm-clear-transfer-cache"), + ) + } + } +} + @Composable private fun DeleteAllTransfersPanel( onCancel: () -> Unit, 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 65d393a..7d9535a 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -137,6 +137,57 @@ class ViewModelsTest { assertEquals("Ada ", viewModel.state.value.username) } + @Test + fun settingsClearsTransferCacheExplicitly() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + clearTransferCacheResult = Result.success(3_700_000_000UL) + } + val viewModel = settingsViewModel(repository = core) + advanceUntilIdle() + + viewModel.clearTransferCache() + advanceUntilIdle() + + assertEquals(1, core.clearTransferCacheCount) + assertFalse(viewModel.state.value.isClearingTransferCache) + } + + @Test + fun settingsDoesNotClearTransferCacheDuringActiveNetworkWork() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + mutableState.value = CoreState(status = CoreStatus("endpoint", 1UL, 0UL)) + } + val viewModel = settingsViewModel(repository = core) + advanceUntilIdle() + + viewModel.clearTransferCache() + advanceUntilIdle() + + assertEquals(0, core.clearTransferCacheCount) + } + + @Test + fun settingsDeleteAllTransfersImmediatelyClearsUnusedCache() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + mutableState.value = CoreState( + isInitialized = true, + transfers = listOf(receivedTransfer(41UL, TransferStatus.Done)), + ) + } + val viewModel = settingsViewModel(repository = core) + advanceUntilIdle() + + viewModel.deleteAllTransfers() + advanceUntilIdle() + + assertEquals(listOf(41UL), core.deletedTransfers) + assertEquals(1, core.clearTransferCacheCount) + assertFalse(viewModel.state.value.isDeletingTransfers) + } + @Test fun settingsAppliesNormalizedCustomRelaysAndPersistsThem() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) 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 10ad3a0..4e09b09 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -47,10 +47,12 @@ class FakeCoreGateway : CoreGateway { var receiveSuspend: Boolean = false private var receiveGate: CompletableDeferred? = null var deleteResult: Result = Result.success(Unit) + var clearTransferCacheResult: Result = Result.success(0UL) var clearReceiveHistoryResult: Result = Result.success(0UL) val deletedTransfers = mutableListOf() val cancelledTransfers = mutableListOf() var clearReceiveHistoryCount = 0 + var clearTransferCacheCount = 0 var receiveCount = 0 var lastReceiveTicket: String? = null var lastReceiveReceiverName: String? = null @@ -154,6 +156,10 @@ class FakeCoreGateway : CoreGateway { override suspend fun storageUsage(): Result = Result.success( CoreStorageUsageModel(0UL, 0UL, 0UL, 0UL, 0UL), ) + override suspend fun clearTransferCache(): Result { + clearTransferCacheCount += 1 + return clearTransferCacheResult + } override suspend fun receivedArtifacts(): Result> = Result.success(emptyList()) override suspend fun cancel(transferId: ULong): Result { cancelledTransfers += transferId @@ -252,7 +258,7 @@ class FakeFileSystemService( override suspend fun validateReceiveFolder(folder: ReceiveFolder) = FolderAccessStatus.Writable override suspend fun inspectReceivedArtifacts(artifacts: List) = ReceivedStorageInspection(artifacts.fold(0UL) { total, item -> total + item.logicalSize }, artifacts.size, 0, 0) - override suspend fun temporaryUsage(): ULong = 0UL + override suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong = 0UL 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 8e1541b..c566035 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 @@ -4,6 +4,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import uniffi.vnidrop.ReceiveOutputSinkV2 import java.io.File +import java.nio.file.Files +import java.nio.file.LinkOption @Composable actual fun rememberFileSystemService(): FileSystemService = @@ -42,7 +44,9 @@ private class JvmFileSystemService : FileSystemService { return ReceivedStorageInspection(bytes, existing, missing, 0) } - override suspend fun temporaryUsage(): ULong = 0UL + override suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong { + return desktopTemporaryUsage(receiveFolder) + } override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = null @@ -65,3 +69,22 @@ private class JvmFileSystemService : FileSystemService { return repository.shareSources(sources, transferName, senderName, accessPolicy) } } + +internal fun desktopTemporaryUsage(receiveFolder: ReceiveFolder): ULong { + if (receiveFolder.kind != ReceiveFolderKind.FileSystemPath) return 0UL + val root = File(receiveFolder.value).toPath() + if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) return 0UL + return runCatching { + Files.walk(root).use { paths -> + paths.iterator().asSequence() + .filter { path -> + val name = path.fileName?.toString().orEmpty() + Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) && + name.startsWith(".") && + name.contains(".vnidrop-") && + name.endsWith(".part") + } + .fold(0UL) { total, path -> total + Files.size(path).toULong() } + } + }.getOrDefault(0UL) +} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/core/CoreRepositoryStorageTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/CoreRepositoryStorageTest.kt new file mode 100644 index 0000000..4665b54 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/CoreRepositoryStorageTest.kt @@ -0,0 +1,43 @@ +package com.vnidrop.app.core + +import java.nio.file.Files +import kotlin.io.path.createTempDirectory +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CoreRepositoryStorageTest { + @Test + fun cacheClearWaitsForActiveSharesThenRestartsWithTheSameIdentity() = runTest { + val appData = createTempDirectory("vnidrop-cache-clear") + val source = Files.write(appData.resolve("source.bin"), ByteArray(64 * 1024) { 5 }) + val repository = CoreRepository() + try { + assertTrue( + repository.initialize( + appData.toString(), + RelaySettings(mode = RelayMode.LocalOnly), + ).isSuccess, + ) + val endpointId = repository.state.value.status?.endpointId + val share = repository.sharePath( + path = source.toString(), + transferName = "source.bin", + senderName = "Sender", + accessPolicy = ShareAccessPolicy.RequireApproval, + ).getOrThrow() + + assertTrue(repository.clearTransferCache().isFailure) + assertTrue(repository.state.value.isInitialized) + + repository.delete(share.transferId).getOrThrow() + assertTrue(repository.clearTransferCache().getOrThrow() >= 64UL * 1024UL) + assertTrue(repository.state.value.isInitialized) + assertEquals(endpointId, repository.state.value.status?.endpointId) + } finally { + repository.shutdown() + appData.toFile().deleteRecursively() + } + } +} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/core/FileSystemServiceTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/FileSystemServiceTest.kt new file mode 100644 index 0000000..b8909f7 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/FileSystemServiceTest.kt @@ -0,0 +1,29 @@ +package com.vnidrop.app.core + +import java.nio.file.Files +import kotlin.io.path.createDirectories +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals + +class FileSystemServiceTest { + @Test + fun desktopTemporaryUsageCountsOnlyVnidropPartFiles() { + val root = createTempDirectory("vnidrop-temporary-usage") + try { + val nested = root.resolve("nested").createDirectories() + Files.write(nested.resolve(".photo.jpg.vnidrop-test.part"), ByteArray(7)) + Files.write(nested.resolve("photo.jpg"), ByteArray(13)) + Files.write(nested.resolve(".unrelated.part"), ByteArray(17)) + + assertEquals( + 7UL, + desktopTemporaryUsage( + ReceiveFolder(ReceiveFolderKind.FileSystemPath, root.toString(), "Test"), + ), + ) + } 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 aeffd9b..bbf7060 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -176,6 +176,7 @@ class FoundationComposeTest { @Test fun storageDeleteAllTransfersRequiresConfirmation() = runComposeUiTest { var deleteRequested = false + var cacheClearRequested = false setContent { VniDropTheme(isDarkTheme = false) { SettingsScreen( @@ -196,15 +197,24 @@ class FoundationComposeTest { onBugIncludeLogsChanged = {}, onSubmitBugReport = {}, onDeleteAllTransfers = { deleteRequested = true }, + onClearTransferCache = { cacheClearRequested = true }, ) } } + onNodeWithText("Clear transfer cache").performClick() + onNodeWithText( + "Removes cached transfer content after briefly restarting VniDrop. " + + "Finish ongoing transfers and stop active shares first. Received files and transfer history are not deleted.", + ).assertIsDisplayed() + runOnIdle { assertFalse(cacheClearRequested) } + onNodeWithTag("confirm-clear-transfer-cache").performClick() + runOnIdle { assertTrue(cacheClearRequested) } + onNodeWithText("Delete all transfers").performClick() 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.", + "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() runOnIdle { assertFalse(deleteRequested) }