2 Commits

31 changed files with 601 additions and 83 deletions

View File

@@ -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, reclaimable after publication. Restart reconciliation repairs active-share tags,
removes orphan share tags, and never restores a stopped share. 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 ## Resource Limits
`CoreLimits` controls source count, collection files and bytes, path and ticket `CoreLimits` controls source count, collection files and bytes, path and ticket

View File

@@ -2,8 +2,9 @@ use anyhow::Context;
use iroh::RelayUrl; use iroh::RelayUrl;
use iroh_blobs::Hash; use iroh_blobs::Hash;
use serde::{Deserialize, Serialize}; 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}; use crate::util::{non_empty, now_ms};
pub(crate) const MAX_CUSTOM_RELAYS: usize = 8; pub(crate) const MAX_CUSTOM_RELAYS: usize = 8;
@@ -126,6 +127,49 @@ pub fn default_core_network_config() -> CoreNetworkConfig {
CoreNetworkConfig::default() 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<u64, VnidropError> {
let result = (|| -> anyhow::Result<u64> {
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)] #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct CoreLimits { pub struct CoreLimits {
pub max_sources: u64, pub max_sources: u64,

View File

@@ -14,11 +14,11 @@ mod transfer_state;
mod util; mod util;
pub use api::{ pub use api::{
default_core_limits, default_core_network_config, CoreEvent, CoreEventSink, CoreLimits, clear_inactive_transfer_cache, default_core_limits, default_core_network_config, CoreEvent,
CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, PublishedOutput, ReceiveOutputSink, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, PublishedOutput,
ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest,
ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, TicketInspection, RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer,
TransferAccessMode, TransferMetadata, TicketInspection, TransferAccessMode, TransferMetadata,
}; };
pub use error::VnidropError; pub use error::VnidropError;
pub use runtime::VnidropCore; pub use runtime::VnidropCore;

View File

@@ -16,6 +16,8 @@ mod share;
mod storage; mod storage;
pub use facade::VnidropCore; pub use facade::VnidropCore;
#[cfg(test)]
pub(crate) use provider::{consume_request_updates, RequestStreamOutcome};
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},

View File

@@ -10,6 +10,31 @@ use tokio::sync::mpsc;
use super::CoreInner; use super::CoreInner;
use crate::access_policy::AccessDecision; use crate::access_policy::AccessDecision;
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum RequestStreamOutcome {
TerminalUpdateReceived,
Aborted,
}
pub(crate) async fn consume_request_updates(
mut rx: irpc::channel::mpsc::Receiver<RequestUpdate>,
mut handle_update: impl FnMut(RequestUpdate),
) -> RequestStreamOutcome {
let mut terminal_update_received = false;
while let Ok(Some(update)) = rx.recv().await {
terminal_update_received |= matches!(
update,
RequestUpdate::Completed(_) | RequestUpdate::Aborted(_)
);
handle_update(update);
}
if terminal_update_received {
RequestStreamOutcome::TerminalUpdateReceived
} else {
RequestStreamOutcome::Aborted
}
}
impl CoreInner { impl CoreInner {
pub(super) async fn spawn_provider_event_task( pub(super) async fn spawn_provider_event_task(
self: &Arc<Self>, self: &Arc<Self>,
@@ -302,7 +327,7 @@ impl CoreInner {
transfer_id: u64, transfer_id: u64,
connection_id: u64, connection_id: u64,
request_id: u64, request_id: u64,
mut rx: irpc::channel::mpsc::Receiver<RequestUpdate>, rx: irpc::channel::mpsc::Receiver<RequestUpdate>,
) { ) {
// Request update tasks are tied to individual provider streams. Router // Request update tasks are tied to individual provider streams. Router
// shutdown closes those streams; only the long-lived provider receiver // shutdown closes those streams; only the long-lived provider receiver
@@ -318,35 +343,35 @@ impl CoreInner {
.cloned(); .cloned();
let core = self.clone(); let core = self.clone();
tokio::spawn(async move { tokio::spawn(async move {
while let Ok(Some(update)) = rx.recv().await { let outcome = consume_request_updates(rx, |update| match update {
match update { RequestUpdate::Started(started) => core.emit_transfer(
RequestUpdate::Started(started) => core.emit_transfer( transfer_id,
transfer_id, "send",
"send", "transfer",
"transfer", "started",
"started", json!({
json!({ "connection_id": connection_id,
"connection_id": connection_id, "request_id": request_id,
"request_id": request_id, "endpoint_id": endpoint_id,
"endpoint_id": endpoint_id, "hash": started.hash.to_string(),
"hash": started.hash.to_string(), "size": started.size,
"size": started.size, "index": started.index,
"index": started.index, }),
}), ),
), RequestUpdate::Progress(progress) => core.emit_transfer(
RequestUpdate::Progress(progress) => core.emit_transfer( transfer_id,
transfer_id, "send",
"send", "transfer",
"transfer", "progress",
"progress", json!({
json!({ "connection_id": connection_id,
"connection_id": connection_id, "request_id": request_id,
"request_id": request_id, "endpoint_id": endpoint_id,
"endpoint_id": endpoint_id, "end_offset": progress.end_offset,
"end_offset": progress.end_offset, }),
}), ),
), RequestUpdate::Completed(_) => {
RequestUpdate::Completed(_) => core.emit_transfer( core.emit_transfer(
transfer_id, transfer_id,
"send", "send",
"transfer", "transfer",
@@ -356,8 +381,10 @@ impl CoreInner {
"request_id": request_id, "request_id": request_id,
"endpoint_id": endpoint_id, "endpoint_id": endpoint_id,
}), }),
), );
RequestUpdate::Aborted(_) => core.emit_transfer( }
RequestUpdate::Aborted(_) => {
core.emit_transfer(
transfer_id, transfer_id,
"send", "send",
"transfer", "transfer",
@@ -367,8 +394,22 @@ impl CoreInner {
"request_id": request_id, "request_id": request_id,
"endpoint_id": endpoint_id, "endpoint_id": endpoint_id,
}), }),
), );
} }
})
.await;
if outcome == RequestStreamOutcome::Aborted {
core.emit_transfer(
transfer_id,
"send",
"transfer",
"aborted",
json!({
"connection_id": connection_id,
"request_id": request_id,
"endpoint_id": endpoint_id,
}),
);
} }
}); });
} }

View File

@@ -1,9 +1,16 @@
use std::sync::Arc; use std::{sync::Arc, time::Duration};
use iroh_blobs::Hash; use iroh_blobs::{
provider::{
events::{RequestUpdate, TransferCompleted},
TransferStats,
},
Hash,
};
use crate::{ use crate::{
repository::{PendingDeliveryReceiptInsert, Repository, TransferUpsert}, repository::{PendingDeliveryReceiptInsert, Repository, TransferUpsert},
runtime::{consume_request_updates, RequestStreamOutcome},
transfer_state::{TransferDirection, TransferStatus}, transfer_state::{TransferDirection, TransferStatus},
CoreEvent, CoreEventSink, VnidropCore, VnidropError, CoreEvent, CoreEventSink, VnidropCore, VnidropError,
}; };
@@ -14,6 +21,37 @@ impl CoreEventSink for TestSink {
fn on_event(&self, _event: CoreEvent) {} fn on_event(&self, _event: CoreEvent) {}
} }
#[test]
fn provider_request_stream_distinguishes_success_from_silent_abort() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async {
let (completed_tx, completed_rx) = irpc::channel::mpsc::channel(1);
completed_tx
.send(RequestUpdate::Completed(TransferCompleted {
stats: Box::new(TransferStats {
payload_bytes_sent: 5,
other_bytes_sent: 0,
other_bytes_read: 0,
duration: Duration::ZERO,
}),
}))
.await
.unwrap();
drop(completed_tx);
assert_eq!(
consume_request_updates(completed_rx, |_| {}).await,
RequestStreamOutcome::TerminalUpdateReceived
);
let (aborted_tx, aborted_rx) = irpc::channel::mpsc::channel::<RequestUpdate>(1);
drop(aborted_tx);
assert_eq!(
consume_request_updates(aborted_rx, |_| {}).await,
RequestStreamOutcome::Aborted
);
});
}
#[test] #[test]
fn initializes_and_reports_endpoint() { fn initializes_and_reports_endpoint() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();

View File

@@ -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());
}

View File

@@ -1,7 +1,28 @@
mod support; mod support;
use std::time::{Duration, Instant};
use support::{receive_with_response, share_path, TestNode}; use support::{receive_with_response, share_path, TestNode};
use vnidrop::VnidropError; use vnidrop::{CoreEvent, VnidropError};
fn wait_for_sender_transfer_event(sender: &TestNode, transfer_id: u64, kind: &str) -> CoreEvent {
let started = Instant::now();
loop {
if let Some(event) = sender.sink.events().into_iter().find(|event| {
event.transfer_id == Some(transfer_id)
&& event.direction.as_deref() == Some("send")
&& event.phase == "transfer"
&& event.kind == kind
}) {
return event;
}
assert!(
started.elapsed() < Duration::from_secs(5),
"timed out waiting for sender transfer event {kind}"
);
std::thread::sleep(Duration::from_millis(10));
}
}
#[test] #[test]
fn transfers_file_between_two_cores() { fn transfers_file_between_two_cores() {
@@ -50,6 +71,12 @@ fn transfers_file_between_two_cores() {
artifacts[0].locator, artifacts[0].locator,
output_dir.path().join("hello.txt").to_string_lossy() output_dir.path().join("hello.txt").to_string_lossy()
); );
let completed = wait_for_sender_transfer_event(&sender, share.transfer_id, "completed");
assert!(completed.data_json.contains("\"connection_id\":"));
assert!(completed.data_json.contains("\"request_id\":"));
assert!(completed
.data_json
.contains(receiver.core.status().endpoint_id.as_str()));
receiver.core.delete_receive_history().unwrap(); receiver.core.delete_receive_history().unwrap();
assert_eq!(receiver.core.list_received_artifacts().unwrap(), artifacts); assert_eq!(receiver.core.list_received_artifacts().unwrap(), artifacts);

View File

@@ -103,7 +103,7 @@ private class AndroidFileSystemService(
return ReceivedStorageInspection(bytes, existing, missing, inaccessible) 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? = override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? =
when (folder.kind) { when (folder.kind) {

View File

@@ -231,16 +231,20 @@
<string name="status_receiving">Wird empfangen</string> <string name="status_receiving">Wird empfangen</string>
<string name="status_stopped">Beendet</string> <string name="status_stopped">Beendet</string>
<string name="storage_calculating">Wird berechnet…</string> <string name="storage_calculating">Wird berechnet…</string>
<string name="storage_clear_transfer_cache">Übertragungscache leeren</string>
<string name="storage_clear_transfer_cache_description">Entfernt zwischengespeicherte Übertragungsinhalte, die nicht von einem laufenden Empfang oder einer aktiven Freigabe verwendet werden. Empfangene Dateien und der Übertragungsverlauf werden nicht gelöscht.</string>
<string name="storage_clearing_transfer_cache">Cache wird geleert…</string>
<string name="storage_delete_transfers">Alle Übertragungen löschen</string> <string name="storage_delete_transfers">Alle Übertragungen löschen</string>
<string name="storage_delete_transfers_description">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.</string> <string name="storage_delete_transfers_description">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.</string>
<string name="storage_app_data">App-Daten</string> <string name="storage_app_data">App-Daten</string>
<string name="storage_deleting">Wird gelöscht…</string> <string name="storage_deleting">Wird gelöscht…</string>
<string name="storage_footer">Ü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.</string> <string name="storage_footer">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.</string>
<string name="storage_received_files">Empfangene Dateien</string> <string name="storage_received_files">Empfangene Dateien</string>
<string name="storage_temporary">Temporäre Dateien</string> <string name="storage_temporary">Temporäre Dateien</string>
<string name="storage_title">Speicher</string> <string name="storage_title">Speicher</string>
<string name="storage_total">Gesamt</string> <string name="storage_total">Gesamt</string>
<string name="storage_transfer_data">Übertragungsdaten</string> <string name="storage_transfer_data">Übertragungscache</string>
<string name="storage_transfer_cache_cleared">Übertragungscache geleert</string>
<string name="storage_transfers_deleted">Alle Übertragungen gelöscht</string> <string name="storage_transfers_deleted">Alle Übertragungen gelöscht</string>
<string name="transfer_activity_description">Wichtige Aktualisierungen zu dieser Übertragung ansehen</string> <string name="transfer_activity_description">Wichtige Aktualisierungen zu dieser Übertragung ansehen</string>
<string name="transfer_activity_title">Aktivität</string> <string name="transfer_activity_title">Aktivität</string>

View File

@@ -231,16 +231,20 @@
<string name="status_receiving">Recibiendo</string> <string name="status_receiving">Recibiendo</string>
<string name="status_stopped">Detenido</string> <string name="status_stopped">Detenido</string>
<string name="storage_calculating">Calculando…</string> <string name="storage_calculating">Calculando…</string>
<string name="storage_clear_transfer_cache">Borrar caché de transferencias</string>
<string name="storage_clear_transfer_cache_description">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.</string>
<string name="storage_clearing_transfer_cache">Borrando caché…</string>
<string name="storage_delete_transfers">Eliminar todas las transferencias</string> <string name="storage_delete_transfers">Eliminar todas las transferencias</string>
<string name="storage_delete_transfers_description">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.</string> <string name="storage_delete_transfers_description">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.</string>
<string name="storage_app_data">Datos de la aplicación</string> <string name="storage_app_data">Datos de la aplicación</string>
<string name="storage_deleting">Eliminando…</string> <string name="storage_deleting">Eliminando…</string>
<string name="storage_footer">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í.</string> <string name="storage_footer">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í.</string>
<string name="storage_received_files">Archivos recibidos</string> <string name="storage_received_files">Archivos recibidos</string>
<string name="storage_temporary">Archivos temporales</string> <string name="storage_temporary">Archivos temporales</string>
<string name="storage_title">Almacenamiento</string> <string name="storage_title">Almacenamiento</string>
<string name="storage_total">Total</string> <string name="storage_total">Total</string>
<string name="storage_transfer_data">Datos de transferencia</string> <string name="storage_transfer_data">Caché de transferencias</string>
<string name="storage_transfer_cache_cleared">Caché de transferencias borrada</string>
<string name="storage_transfers_deleted">Todas las transferencias eliminadas</string> <string name="storage_transfers_deleted">Todas las transferencias eliminadas</string>
<string name="transfer_activity_description">Vea las actualizaciones importantes de esta transferencia</string> <string name="transfer_activity_description">Vea las actualizaciones importantes de esta transferencia</string>
<string name="transfer_activity_title">Actividad</string> <string name="transfer_activity_title">Actividad</string>

View File

@@ -231,16 +231,20 @@
<string name="status_receiving">Réception</string> <string name="status_receiving">Réception</string>
<string name="status_stopped">Arrêté</string> <string name="status_stopped">Arrêté</string>
<string name="storage_calculating">Calcul…</string> <string name="storage_calculating">Calcul…</string>
<string name="storage_clear_transfer_cache">Vider le cache des transferts</string>
<string name="storage_clear_transfer_cache_description">Supprime le contenu de transfert en cache qui nest pas utilisé par une réception en cours ou un partage actif. Les fichiers reçus et lhistorique ne sont pas supprimés.</string>
<string name="storage_clearing_transfer_cache">Vidage du cache…</string>
<string name="storage_delete_transfers">Supprimer tous les transferts</string> <string name="storage_delete_transfers">Supprimer tous les transferts</string>
<string name="storage_delete_transfers_description">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 nest plus nécessaire est récupéré automatiquement, ce qui peut prendre un peu de temps. Cette action est irréversible.</string> <string name="storage_delete_transfers_description">Cela efface tous les transferts envoyés et reçus de lhistorique 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.</string>
<string name="storage_app_data">Données de lapp</string> <string name="storage_app_data">Données de lapp</string>
<string name="storage_deleting">Suppression…</string> <string name="storage_deleting">Suppression…</string>
<string name="storage_footer">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.</string> <string name="storage_footer">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.</string>
<string name="storage_received_files">Fichiers reçus</string> <string name="storage_received_files">Fichiers reçus</string>
<string name="storage_temporary">Fichiers temporaires</string> <string name="storage_temporary">Fichiers temporaires</string>
<string name="storage_title">Stockage</string> <string name="storage_title">Stockage</string>
<string name="storage_total">Total</string> <string name="storage_total">Total</string>
<string name="storage_transfer_data">Données de transfert</string> <string name="storage_transfer_data">Cache des transferts</string>
<string name="storage_transfer_cache_cleared">Cache des transferts vidé</string>
<string name="storage_transfers_deleted">Tous les transferts supprimés</string> <string name="storage_transfers_deleted">Tous les transferts supprimés</string>
<string name="transfer_activity_description">Consultez les mises à jour importantes de ce transfert</string> <string name="transfer_activity_description">Consultez les mises à jour importantes de ce transfert</string>
<string name="transfer_activity_title">Activité</string> <string name="transfer_activity_title">Activité</string>

View File

@@ -231,16 +231,20 @@
<string name="status_receiving">Ricezione</string> <string name="status_receiving">Ricezione</string>
<string name="status_stopped">Interrotto</string> <string name="status_stopped">Interrotto</string>
<string name="storage_calculating">Calcolo…</string> <string name="storage_calculating">Calcolo…</string>
<string name="storage_clear_transfer_cache">Svuota cache trasferimenti</string>
<string name="storage_clear_transfer_cache_description">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.</string>
<string name="storage_clearing_transfer_cache">Svuotamento cache…</string>
<string name="storage_delete_transfers">Elimina tutti i trasferimenti</string> <string name="storage_delete_transfers">Elimina tutti i trasferimenti</string>
<string name="storage_delete_transfers_description">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.</string> <string name="storage_delete_transfers_description">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.</string>
<string name="storage_app_data">Dati dellapp</string> <string name="storage_app_data">Dati dellapp</string>
<string name="storage_deleting">Eliminazione…</string> <string name="storage_deleting">Eliminazione…</string>
<string name="storage_footer">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.</string> <string name="storage_footer">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.</string>
<string name="storage_received_files">File ricevuti</string> <string name="storage_received_files">File ricevuti</string>
<string name="storage_temporary">File temporanei</string> <string name="storage_temporary">File temporanei</string>
<string name="storage_title">Archiviazione</string> <string name="storage_title">Archiviazione</string>
<string name="storage_total">Totale</string> <string name="storage_total">Totale</string>
<string name="storage_transfer_data">Dati di trasferimento</string> <string name="storage_transfer_data">Cache trasferimenti</string>
<string name="storage_transfer_cache_cleared">Cache trasferimenti svuotata</string>
<string name="storage_transfers_deleted">Tutti i trasferimenti eliminati</string> <string name="storage_transfers_deleted">Tutti i trasferimenti eliminati</string>
<string name="transfer_activity_description">Veda gli aggiornamenti importanti di questo trasferimento</string> <string name="transfer_activity_description">Veda gli aggiornamenti importanti di questo trasferimento</string>
<string name="transfer_activity_title">Attività</string> <string name="transfer_activity_title">Attività</string>

View File

@@ -231,16 +231,20 @@
<string name="status_receiving">Ontvangen</string> <string name="status_receiving">Ontvangen</string>
<string name="status_stopped">Gestopt</string> <string name="status_stopped">Gestopt</string>
<string name="storage_calculating">Berekenen…</string> <string name="storage_calculating">Berekenen…</string>
<string name="storage_clear_transfer_cache">Overdrachtscache wissen</string>
<string name="storage_clear_transfer_cache_description">Verwijdert overdrachtsinhoud uit de cache die niet wordt gebruikt door een lopende ontvangst of actieve share. Ontvangen bestanden en de overdrachtsgeschiedenis worden niet verwijderd.</string>
<string name="storage_clearing_transfer_cache">Cache wissen…</string>
<string name="storage_delete_transfers">Alle overdrachten verwijderen</string> <string name="storage_delete_transfers">Alle overdrachten verwijderen</string>
<string name="storage_delete_transfers_description">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.</string> <string name="storage_delete_transfers_description">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.</string>
<string name="storage_app_data">Appgegevens</string> <string name="storage_app_data">Appgegevens</string>
<string name="storage_deleting">Verwijderen…</string> <string name="storage_deleting">Verwijderen…</string>
<string name="storage_footer">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.</string> <string name="storage_footer">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.</string>
<string name="storage_received_files">Ontvangen bestanden</string> <string name="storage_received_files">Ontvangen bestanden</string>
<string name="storage_temporary">Tijdelijke bestanden</string> <string name="storage_temporary">Tijdelijke bestanden</string>
<string name="storage_title">Opslag</string> <string name="storage_title">Opslag</string>
<string name="storage_total">Totaal</string> <string name="storage_total">Totaal</string>
<string name="storage_transfer_data">Overdrachtsgegevens</string> <string name="storage_transfer_data">Overdrachtscache</string>
<string name="storage_transfer_cache_cleared">Overdrachtscache gewist</string>
<string name="storage_transfers_deleted">Alle overdrachten verwijderd</string> <string name="storage_transfers_deleted">Alle overdrachten verwijderd</string>
<string name="transfer_activity_description">Bekijk belangrijke updates voor deze overdracht</string> <string name="transfer_activity_description">Bekijk belangrijke updates voor deze overdracht</string>
<string name="transfer_activity_title">Activiteit</string> <string name="transfer_activity_title">Activiteit</string>

View File

@@ -231,16 +231,20 @@
<string name="status_receiving">Odbieranie</string> <string name="status_receiving">Odbieranie</string>
<string name="status_stopped">Zatrzymany</string> <string name="status_stopped">Zatrzymany</string>
<string name="storage_calculating">Obliczanie…</string> <string name="storage_calculating">Obliczanie…</string>
<string name="storage_clear_transfer_cache">Wyczyść pamięć podręczną transferów</string>
<string name="storage_clear_transfer_cache_description">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.</string>
<string name="storage_clearing_transfer_cache">Czyszczenie pamięci podręcznej…</string>
<string name="storage_delete_transfers">Usuń wszystkie transfery</string> <string name="storage_delete_transfers">Usuń wszystkie transfery</string>
<string name="storage_delete_transfers_description">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ąć.</string> <string name="storage_delete_transfers_description">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ąć.</string>
<string name="storage_app_data">Dane aplikacji</string> <string name="storage_app_data">Dane aplikacji</string>
<string name="storage_deleting">Usuwanie…</string> <string name="storage_deleting">Usuwanie…</string>
<string name="storage_footer">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.</string> <string name="storage_footer">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.</string>
<string name="storage_received_files">Odebrane pliki</string> <string name="storage_received_files">Odebrane pliki</string>
<string name="storage_temporary">Pliki tymczasowe</string> <string name="storage_temporary">Pliki tymczasowe</string>
<string name="storage_title">Pamięć</string> <string name="storage_title">Pamięć</string>
<string name="storage_total">Łącznie</string> <string name="storage_total">Łącznie</string>
<string name="storage_transfer_data">Dane transferu</string> <string name="storage_transfer_data">Pamięć podręczna transferów</string>
<string name="storage_transfer_cache_cleared">Wyczyszczono pamięć podręczną transferów</string>
<string name="storage_transfers_deleted">Usunięto wszystkie transfery</string> <string name="storage_transfers_deleted">Usunięto wszystkie transfery</string>
<string name="transfer_activity_description">Zobacz ważne aktualizacje tego transferu</string> <string name="transfer_activity_description">Zobacz ważne aktualizacje tego transferu</string>
<string name="transfer_activity_title">Aktywność</string> <string name="transfer_activity_title">Aktywność</string>

View File

@@ -231,16 +231,20 @@
<string name="status_receiving">A receber</string> <string name="status_receiving">A receber</string>
<string name="status_stopped">Parada</string> <string name="status_stopped">Parada</string>
<string name="storage_calculating">A calcular…</string> <string name="storage_calculating">A calcular…</string>
<string name="storage_clear_transfer_cache">Limpar cache de transferências</string>
<string name="storage_clear_transfer_cache_description">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.</string>
<string name="storage_clearing_transfer_cache">A limpar cache…</string>
<string name="storage_delete_transfers">Eliminar todas as transferências</string> <string name="storage_delete_transfers">Eliminar todas as transferências</string>
<string name="storage_delete_transfers_description">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.</string> <string name="storage_delete_transfers_description">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.</string>
<string name="storage_app_data">Dados da aplicação</string> <string name="storage_app_data">Dados da aplicação</string>
<string name="storage_deleting">A eliminar…</string> <string name="storage_deleting">A eliminar…</string>
<string name="storage_footer">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.</string> <string name="storage_footer">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.</string>
<string name="storage_received_files">Ficheiros recebidos</string> <string name="storage_received_files">Ficheiros recebidos</string>
<string name="storage_temporary">Ficheiros temporários</string> <string name="storage_temporary">Ficheiros temporários</string>
<string name="storage_title">Armazenamento</string> <string name="storage_title">Armazenamento</string>
<string name="storage_total">Total</string> <string name="storage_total">Total</string>
<string name="storage_transfer_data">Dados de transferência</string> <string name="storage_transfer_data">Cache de transferências</string>
<string name="storage_transfer_cache_cleared">Cache de transferências limpa</string>
<string name="storage_transfers_deleted">Todas as transferências eliminadas</string> <string name="storage_transfers_deleted">Todas as transferências eliminadas</string>
<string name="transfer_activity_description">Ver as atualizações importantes desta transferência</string> <string name="transfer_activity_description">Ver as atualizações importantes desta transferência</string>
<string name="transfer_activity_title">Atividade</string> <string name="transfer_activity_title">Atividade</string>

View File

@@ -231,16 +231,20 @@
<string name="status_receiving">Получение</string> <string name="status_receiving">Получение</string>
<string name="status_stopped">Остановлено</string> <string name="status_stopped">Остановлено</string>
<string name="storage_calculating">Вычисление…</string> <string name="storage_calculating">Вычисление…</string>
<string name="storage_clear_transfer_cache">Очистить кэш передач</string>
<string name="storage_clear_transfer_cache_description">Удаляет кэшированное содержимое передач, которое не используется текущим приёмом или активной раздачей. Полученные файлы и история передач не удаляются.</string>
<string name="storage_clearing_transfer_cache">Очистка кэша…</string>
<string name="storage_delete_transfers">Удалить все передачи</string> <string name="storage_delete_transfers">Удалить все передачи</string>
<string name="storage_delete_transfers_description">Это удалит из истории все записи об отправленных и полученных передачах. Полученные файлы не удаляются. Кэшированное общее содержимое, которое больше не требуется, освобождается автоматически; это может занять некоторое время. Это действие нельзя отменить.</string> <string name="storage_delete_transfers_description">Это удалит из истории все отправленные и полученные передачи и немедленно освободит неиспользуемый кэш. Текущие передачи и полученные файлы не удаляются. Это действие нельзя отменить.</string>
<string name="storage_app_data">Данные приложения</string> <string name="storage_app_data">Данные приложения</string>
<string name="storage_deleting">Удаление…</string> <string name="storage_deleting">Удаление…</string>
<string name="storage_footer">Данные передачи включают историю и кэшированное содержимое активных раздач. Ненужный кэш освобождается автоматически после удаления записей; это может занять некоторое время. Полученные файлы учитываются в этой сводке, но никогда не удаляются здесь.</string> <string name="storage_footer">Кэш передач содержит загруженные или импортированные данные, включая содержимое, защищённое текущими приёмами и активными раздачами. Очистка удаляет только неиспользуемый кэш. Полученные файлы здесь никогда не удаляются.</string>
<string name="storage_received_files">Полученные файлы</string> <string name="storage_received_files">Полученные файлы</string>
<string name="storage_temporary">Временные файлы</string> <string name="storage_temporary">Временные файлы</string>
<string name="storage_title">Хранилище</string> <string name="storage_title">Хранилище</string>
<string name="storage_total">Всего</string> <string name="storage_total">Всего</string>
<string name="storage_transfer_data">Данные передачи</string> <string name="storage_transfer_data">Кэш передач</string>
<string name="storage_transfer_cache_cleared">Кэш передач очищен</string>
<string name="storage_transfers_deleted">Все передачи удалены</string> <string name="storage_transfers_deleted">Все передачи удалены</string>
<string name="transfer_activity_description">Просматривайте важные обновления этой передачи</string> <string name="transfer_activity_description">Просматривайте важные обновления этой передачи</string>
<string name="transfer_activity_title">Активность</string> <string name="transfer_activity_title">Активность</string>

View File

@@ -231,16 +231,20 @@
<string name="status_receiving">Receiving</string> <string name="status_receiving">Receiving</string>
<string name="status_stopped">Stopped</string> <string name="status_stopped">Stopped</string>
<string name="storage_calculating">Calculating…</string> <string name="storage_calculating">Calculating…</string>
<string name="storage_clear_transfer_cache">Clear transfer cache</string>
<string name="storage_clear_transfer_cache_description">Removes cached transfer content after briefly restarting VniDrop. Finish ongoing transfers and stop active shares first. Received files and transfer history are not deleted.</string>
<string name="storage_clearing_transfer_cache">Clearing cache…</string>
<string name="storage_delete_transfers">Delete all transfers</string> <string name="storage_delete_transfers">Delete all transfers</string>
<string name="storage_delete_transfers_description">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 cant be undone.</string> <string name="storage_delete_transfers_description">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 cant be undone.</string>
<string name="storage_app_data">App data</string> <string name="storage_app_data">App data</string>
<string name="storage_deleting">Deleting…</string> <string name="storage_deleting">Deleting…</string>
<string name="storage_footer">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.</string> <string name="storage_footer">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.</string>
<string name="storage_received_files">Received files</string> <string name="storage_received_files">Received files</string>
<string name="storage_temporary">Temporary files</string> <string name="storage_temporary">Temporary files</string>
<string name="storage_title">Storage</string> <string name="storage_title">Storage</string>
<string name="storage_total">Total</string> <string name="storage_total">Total</string>
<string name="storage_transfer_data">Transfer data</string> <string name="storage_transfer_data">Transfer cache</string>
<string name="storage_transfer_cache_cleared">Transfer cache cleared</string>
<string name="storage_transfers_deleted">All transfers deleted</string> <string name="storage_transfers_deleted">All transfers deleted</string>
<string name="transfer_activity_description">See important updates for this transfer</string> <string name="transfer_activity_description">See important updates for this transfer</string>
<string name="transfer_activity_title">Activity</string> <string name="transfer_activity_title">Activity</string>

View File

@@ -182,6 +182,7 @@ interface CoreGateway {
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit> suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit>
suspend fun receiveWithOutputSinkV2(ticket: String, outputSink: ReceiveOutputSinkV2, receiverName: String): Result<Unit> suspend fun receiveWithOutputSinkV2(ticket: String, outputSink: ReceiveOutputSinkV2, receiverName: String): Result<Unit>
suspend fun storageUsage(): Result<CoreStorageUsageModel> suspend fun storageUsage(): Result<CoreStorageUsageModel>
suspend fun clearTransferCache(): Result<ULong>
suspend fun receivedArtifacts(): Result<List<ReceivedArtifactModel>> suspend fun receivedArtifacts(): Result<List<ReceivedArtifactModel>>
suspend fun cancel(transferId: ULong): Result<Unit> suspend fun cancel(transferId: ULong): Result<Unit>
suspend fun delete(transferId: ULong): Result<Unit> suspend fun delete(transferId: ULong): Result<Unit>

View File

@@ -30,6 +30,7 @@ import uniffi.vnidrop.TicketInspection
import uniffi.vnidrop.TransferMetadata import uniffi.vnidrop.TransferMetadata
import uniffi.vnidrop.TransferAccessMode import uniffi.vnidrop.TransferAccessMode
import uniffi.vnidrop.VnidropCore import uniffi.vnidrop.VnidropCore
import uniffi.vnidrop.clearInactiveTransferCache
import uniffi.vnidrop.defaultCoreNetworkConfig import uniffi.vnidrop.defaultCoreNetworkConfig
class CoreRepository( class CoreRepository(
@@ -45,6 +46,8 @@ class CoreRepository(
override val signals: SharedFlow<CoreSignal> = _signals.asSharedFlow() override val signals: SharedFlow<CoreSignal> = _signals.asSharedFlow()
private var core: VnidropCore? = null private var core: VnidropCore? = null
private var currentAppDataDir: String? = null
private var currentRelaySettings = RelaySettings()
private val lifecycleGate = CoreLifecycleGate() private val lifecycleGate = CoreLifecycleGate()
private val sink = object : CoreEventSink { private val sink = object : CoreEventSink {
@@ -77,6 +80,8 @@ class CoreRepository(
previousCore?.shutdown() previousCore?.shutdown()
core = null core = null
core = VnidropCore.initializeWithNetworkConfig(appDataDir, sink, relaySettings.toNative()) core = VnidropCore.initializeWithNetworkConfig(appDataDir, sink, relaySettings.toNative())
currentAppDataDir = appDataDir
currentRelaySettings = relaySettings
refreshSnapshot(requireCore()) refreshSnapshot(requireCore())
_state.update { it.copy(isInitialized = true) } _state.update { it.copy(isInitialized = true) }
} }
@@ -84,6 +89,7 @@ class CoreRepository(
override fun shutdown() { override fun shutdown() {
core?.shutdown() core?.shutdown()
core = null core = null
currentAppDataDir = null
_state.value = CoreState() _state.value = CoreState()
} }
@@ -168,6 +174,28 @@ class CoreRepository(
) )
} }
override suspend fun clearTransferCache(): Result<ULong> = 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<List<ReceivedArtifactModel>> = runCore { activeCore -> override suspend fun receivedArtifacts(): Result<List<ReceivedArtifactModel>> = runCore { activeCore ->
activeCore.listReceivedArtifacts().map { artifact -> activeCore.listReceivedArtifacts().map { artifact ->
ReceivedArtifactModel( ReceivedArtifactModel(

View File

@@ -40,7 +40,7 @@ interface FileSystemService {
if (supportsCustomReceiveFolders) configuredFolder else defaultReceiveFolder() if (supportsCustomReceiveFolders) configuredFolder else defaultReceiveFolder()
suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus
suspend fun inspectReceivedArtifacts(artifacts: List<ReceivedArtifactModel>): ReceivedStorageInspection suspend fun inspectReceivedArtifacts(artifacts: List<ReceivedArtifactModel>): ReceivedStorageInspection
suspend fun temporaryUsage(): ULong suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong
fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2?
fun canRevealReceiveFolder(folder: ReceiveFolder): Boolean = false fun canRevealReceiveFolder(folder: ReceiveFolder): Boolean = false
suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> = suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> =

View File

@@ -41,5 +41,6 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) {
onBugIncludeLogsChanged = viewModel::setBugIncludeLogs, onBugIncludeLogsChanged = viewModel::setBugIncludeLogs,
onSubmitBugReport = viewModel::submitBugReport, onSubmitBugReport = viewModel::submitBugReport,
onDeleteAllTransfers = viewModel::deleteAllTransfers, onDeleteAllTransfers = viewModel::deleteAllTransfers,
onClearTransferCache = viewModel::clearTransferCache,
) )
} }

View File

@@ -31,6 +31,7 @@ fun SettingsScreen(
onBugIncludeLogsChanged: (Boolean) -> Unit, onBugIncludeLogsChanged: (Boolean) -> Unit,
onSubmitBugReport: () -> Unit, onSubmitBugReport: () -> Unit,
onDeleteAllTransfers: () -> Unit = {}, onDeleteAllTransfers: () -> Unit = {},
onClearTransferCache: () -> Unit = {},
onRelayModeChanged: (RelayMode) -> Unit = {}, onRelayModeChanged: (RelayMode) -> Unit = {},
onRelayUrlChanged: (Int, String) -> Unit = { _, _ -> }, onRelayUrlChanged: (Int, String) -> Unit = { _, _ -> },
onAddRelayUrl: () -> Unit = {}, onAddRelayUrl: () -> Unit = {},
@@ -67,6 +68,7 @@ fun SettingsScreen(
onBugIncludeLogsChanged = onBugIncludeLogsChanged, onBugIncludeLogsChanged = onBugIncludeLogsChanged,
onSubmitBugReport = onSubmitBugReport, onSubmitBugReport = onSubmitBugReport,
onDeleteAllTransfers = onDeleteAllTransfers, onDeleteAllTransfers = onDeleteAllTransfers,
onClearTransferCache = onClearTransferCache,
onRelayModeChanged = onRelayModeChanged, onRelayModeChanged = onRelayModeChanged,
onRelayUrlChanged = onRelayUrlChanged, onRelayUrlChanged = onRelayUrlChanged,
onAddRelayUrl = onAddRelayUrl, onAddRelayUrl = onAddRelayUrl,
@@ -107,6 +109,7 @@ fun SettingsScreen(
onBugIncludeLogsChanged = onBugIncludeLogsChanged, onBugIncludeLogsChanged = onBugIncludeLogsChanged,
onSubmitBugReport = onSubmitBugReport, onSubmitBugReport = onSubmitBugReport,
onDeleteAllTransfers = onDeleteAllTransfers, onDeleteAllTransfers = onDeleteAllTransfers,
onClearTransferCache = onClearTransferCache,
onRelayModeChanged = onRelayModeChanged, onRelayModeChanged = onRelayModeChanged,
onRelayUrlChanged = onRelayUrlChanged, onRelayUrlChanged = onRelayUrlChanged,
onAddRelayUrl = onAddRelayUrl, onAddRelayUrl = onAddRelayUrl,
@@ -139,6 +142,7 @@ private fun SettingsSectionContent(
onBugIncludeLogsChanged: (Boolean) -> Unit, onBugIncludeLogsChanged: (Boolean) -> Unit,
onSubmitBugReport: () -> Unit, onSubmitBugReport: () -> Unit,
onDeleteAllTransfers: () -> Unit, onDeleteAllTransfers: () -> Unit,
onClearTransferCache: () -> Unit,
onRelayModeChanged: (RelayMode) -> Unit, onRelayModeChanged: (RelayMode) -> Unit,
onRelayUrlChanged: (Int, String) -> Unit, onRelayUrlChanged: (Int, String) -> Unit,
onAddRelayUrl: () -> Unit, onAddRelayUrl: () -> Unit,
@@ -160,7 +164,14 @@ private fun SettingsSectionContent(
showBack = showBack, showBack = showBack,
) )
SettingsSection.Notifications -> NotificationSettings(state, onNotificationsChanged, onOpenNotificationSettings, onBack, 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( SettingsSection.About -> AboutSettings(
state = state, state = state,
onDiagnosticsChanged = onDiagnosticsChanged, onDiagnosticsChanged = onDiagnosticsChanged,

View File

@@ -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_settings_open_failed
import vnidrop.shared.generated.resources.notifications_unsupported import vnidrop.shared.generated.resources.notifications_unsupported
import vnidrop.shared.generated.resources.relay_settings_applied 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 { enum class SettingsSection {
Overview, Overview,
@@ -111,6 +113,7 @@ data class SettingsState(
val storage: StorageBreakdown? = null, val storage: StorageBreakdown? = null,
val isCalculatingStorage: Boolean = false, val isCalculatingStorage: Boolean = false,
val isDeletingTransfers: Boolean = false, val isDeletingTransfers: Boolean = false,
val isClearingTransferCache: Boolean = false,
) { ) {
val hasRelaySettingsChanges: Boolean val hasRelaySettingsChanges: Boolean
get() = relayMode != savedRelaySettings.mode || get() = relayMode != savedRelaySettings.mode ||
@@ -208,6 +211,7 @@ class SettingsViewModel(
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isCalculatingStorage = true) } _state.update { it.copy(isCalculatingStorage = true) }
try { try {
val receiveFolder = _state.value.receiveFolder ?: fileSystemService.defaultReceiveFolder()
val coreUsage = repository.storageUsage().getOrThrow() val coreUsage = repository.storageUsage().getOrThrow()
val artifacts = repository.receivedArtifacts().getOrThrow() val artifacts = repository.receivedArtifacts().getOrThrow()
val received = fileSystemService.inspectReceivedArtifacts(artifacts) val received = fileSystemService.inspectReceivedArtifacts(artifacts)
@@ -216,7 +220,7 @@ class SettingsViewModel(
storage = StorageBreakdown( storage = StorageBreakdown(
transferCacheBytes = coreUsage.blobStoreBytes, transferCacheBytes = coreUsage.blobStoreBytes,
appDataBytes = coreUsage.appDataBytes, appDataBytes = coreUsage.appDataBytes,
temporaryBytes = fileSystemService.temporaryUsage(), temporaryBytes = fileSystemService.temporaryUsage(receiveFolder),
receivedBytes = received.existingBytes, receivedBytes = received.existingBytes,
receivedFileCount = received.existingCount, receivedFileCount = received.existingCount,
missingReceivedFileCount = received.missingCount, missingReceivedFileCount = received.missingCount,
@@ -235,18 +239,45 @@ class SettingsViewModel(
} }
fun deleteAllTransfers() { fun deleteAllTransfers() {
if (_state.value.isDeletingTransfers) return if (_state.value.isDeletingTransfers || _state.value.isClearingTransferCache) return
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isDeletingTransfers = true) } _state.update { it.copy(isDeletingTransfers = true) }
val failures = repository.state.value.transfers val failures = repository.state.value.transfers
.map { repository.delete(it.transferId) } .map { repository.delete(it.transferId) }
.count { it.isFailure } .count { it.isFailure }
val cleanup = repository.clearTransferCache()
_state.update { it.copy(isDeletingTransfers = false) } _state.update { it.copy(isDeletingTransfers = false) }
if (failures == 0) { loadStorageUsage()
loadStorageUsage() if (failures > 0) {
} else {
messages.error(IllegalStateException("Could not delete $failures transfer records")) 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)
},
)
} }
} }

View File

@@ -33,6 +33,9 @@ import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_cancel import vnidrop.shared.generated.resources.button_cancel
import vnidrop.shared.generated.resources.storage_app_data import vnidrop.shared.generated.resources.storage_app_data
import vnidrop.shared.generated.resources.storage_calculating 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
import vnidrop.shared.generated.resources.storage_delete_transfers_description import vnidrop.shared.generated.resources.storage_delete_transfers_description
import vnidrop.shared.generated.resources.storage_deleting import vnidrop.shared.generated.resources.storage_deleting
@@ -48,10 +51,12 @@ internal fun StorageSettings(
state: SettingsState, state: SettingsState,
windowClass: WindowClass, windowClass: WindowClass,
onDeleteAllTransfers: () -> Unit, onDeleteAllTransfers: () -> Unit,
onClearTransferCache: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
showBack: Boolean, showBack: Boolean,
) { ) {
var showDeleteConfirmation by rememberSaveable { mutableStateOf(false) } var showDeleteConfirmation by rememberSaveable { mutableStateOf(false) }
var showClearCacheConfirmation by rememberSaveable { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SettingsTopBar(stringResource(Res.string.storage_title), onBack, showBack) SettingsTopBar(stringResource(Res.string.storage_title), onBack, showBack)
val storage = state.storage 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( Button(
onClick = { showDeleteConfirmation = true }, onClick = { showDeleteConfirmation = true },
enabled = !state.isDeletingTransfers, enabled = !state.isDeletingTransfers &&
!state.isClearingTransferCache &&
!state.isCalculatingStorage,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
) { ) {
Text(stringResource(if (state.isDeletingTransfers) Res.string.storage_deleting else Res.string.storage_delete_transfers)) 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, color = LocalVniDropColors.current.foregroundLighter,
) )
} }
if (showClearCacheConfirmation) {
AdaptiveDrawer(
windowClass = windowClass,
onDismissRequest = { showClearCacheConfirmation = false },
) {
ClearTransferCachePanel(
onCancel = { showClearCacheConfirmation = false },
onConfirm = {
showClearCacheConfirmation = false
onClearTransferCache()
},
)
}
}
if (showDeleteConfirmation) { if (showDeleteConfirmation) {
AdaptiveDrawer( AdaptiveDrawer(
windowClass = windowClass, 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 @Composable
private fun DeleteAllTransfersPanel( private fun DeleteAllTransfersPanel(
onCancel: () -> Unit, onCancel: () -> Unit,

View File

@@ -137,6 +137,57 @@ class ViewModelsTest {
assertEquals("Ada ", viewModel.state.value.username) 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 @Test
fun settingsAppliesNormalizedCustomRelaysAndPersistsThem() = runTest { fun settingsAppliesNormalizedCustomRelaysAndPersistsThem() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))

View File

@@ -47,10 +47,12 @@ class FakeCoreGateway : CoreGateway {
var receiveSuspend: Boolean = false var receiveSuspend: Boolean = false
private var receiveGate: CompletableDeferred<Unit>? = null private var receiveGate: CompletableDeferred<Unit>? = null
var deleteResult: Result<Unit> = Result.success(Unit) var deleteResult: Result<Unit> = Result.success(Unit)
var clearTransferCacheResult: Result<ULong> = Result.success(0UL)
var clearReceiveHistoryResult: Result<ULong> = Result.success(0UL) var clearReceiveHistoryResult: Result<ULong> = Result.success(0UL)
val deletedTransfers = mutableListOf<ULong>() val deletedTransfers = mutableListOf<ULong>()
val cancelledTransfers = mutableListOf<ULong>() val cancelledTransfers = mutableListOf<ULong>()
var clearReceiveHistoryCount = 0 var clearReceiveHistoryCount = 0
var clearTransferCacheCount = 0
var receiveCount = 0 var receiveCount = 0
var lastReceiveTicket: String? = null var lastReceiveTicket: String? = null
var lastReceiveReceiverName: String? = null var lastReceiveReceiverName: String? = null
@@ -154,6 +156,10 @@ class FakeCoreGateway : CoreGateway {
override suspend fun storageUsage(): Result<CoreStorageUsageModel> = Result.success( override suspend fun storageUsage(): Result<CoreStorageUsageModel> = Result.success(
CoreStorageUsageModel(0UL, 0UL, 0UL, 0UL, 0UL), CoreStorageUsageModel(0UL, 0UL, 0UL, 0UL, 0UL),
) )
override suspend fun clearTransferCache(): Result<ULong> {
clearTransferCacheCount += 1
return clearTransferCacheResult
}
override suspend fun receivedArtifacts(): Result<List<ReceivedArtifactModel>> = Result.success(emptyList()) override suspend fun receivedArtifacts(): Result<List<ReceivedArtifactModel>> = Result.success(emptyList())
override suspend fun cancel(transferId: ULong): Result<Unit> { override suspend fun cancel(transferId: ULong): Result<Unit> {
cancelledTransfers += transferId cancelledTransfers += transferId
@@ -252,7 +258,7 @@ class FakeFileSystemService(
override suspend fun validateReceiveFolder(folder: ReceiveFolder) = FolderAccessStatus.Writable override suspend fun validateReceiveFolder(folder: ReceiveFolder) = FolderAccessStatus.Writable
override suspend fun inspectReceivedArtifacts(artifacts: List<ReceivedArtifactModel>) = override suspend fun inspectReceivedArtifacts(artifacts: List<ReceivedArtifactModel>) =
ReceivedStorageInspection(artifacts.fold(0UL) { total, item -> total + item.logicalSize }, artifacts.size, 0, 0) 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 createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = null
override fun canRevealReceiveFolder(folder: ReceiveFolder) = canRevealFolder override fun canRevealReceiveFolder(folder: ReceiveFolder) = canRevealFolder
override suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> { override suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> {

View File

@@ -4,6 +4,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import uniffi.vnidrop.ReceiveOutputSinkV2 import uniffi.vnidrop.ReceiveOutputSinkV2
import java.io.File import java.io.File
import java.nio.file.Files
import java.nio.file.LinkOption
@Composable @Composable
actual fun rememberFileSystemService(): FileSystemService = actual fun rememberFileSystemService(): FileSystemService =
@@ -42,7 +44,9 @@ private class JvmFileSystemService : FileSystemService {
return ReceivedStorageInspection(bytes, existing, missing, 0) 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 override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = null
@@ -65,3 +69,22 @@ private class JvmFileSystemService : FileSystemService {
return repository.shareSources(sources, transferName, senderName, accessPolicy) 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)
}

View File

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

View File

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

View File

@@ -176,6 +176,7 @@ class FoundationComposeTest {
@Test @Test
fun storageDeleteAllTransfersRequiresConfirmation() = runComposeUiTest { fun storageDeleteAllTransfersRequiresConfirmation() = runComposeUiTest {
var deleteRequested = false var deleteRequested = false
var cacheClearRequested = false
setContent { setContent {
VniDropTheme(isDarkTheme = false) { VniDropTheme(isDarkTheme = false) {
SettingsScreen( SettingsScreen(
@@ -196,15 +197,24 @@ class FoundationComposeTest {
onBugIncludeLogsChanged = {}, onBugIncludeLogsChanged = {},
onSubmitBugReport = {}, onSubmitBugReport = {},
onDeleteAllTransfers = { deleteRequested = true }, 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("Delete all transfers").performClick()
onNodeWithText( onNodeWithText(
"This clears all sent and received transfer records from your history. Your received files are not deleted. " + "This clears all sent and received transfer records from your history and immediately reclaims unused transfer cache. " +
"Cached shared content that is no longer needed is reclaimed automatically, which may take a little time. " + "Ongoing transfers and received files are not deleted. This cant be undone.",
"This cant be undone.",
).assertIsDisplayed() ).assertIsDisplayed()
runOnIdle { assertFalse(deleteRequested) } runOnIdle { assertFalse(deleteRequested) }