diff --git a/apple/Tests/Fakes.swift b/apple/Tests/Fakes.swift index 33d5ba1..ed1e99d 100644 --- a/apple/Tests/Fakes.swift +++ b/apple/Tests/Fakes.swift @@ -62,6 +62,10 @@ final class FakeCoreGateway: CoreGateway { func cancel(transferId: UInt64) async -> Result { cancelledTransfers.append(transferId); return cancelResult } func delete(transferId: UInt64) async -> Result { deletedTransfers.append(transferId); return deleteResult } func clearReceiveHistory() async -> Result { clearReceiveHistoryCount += 1; return clearReceiveHistoryResult } + func storageUsage() async -> Result { + .success(CoreStorageUsageModel(blobStoreBytes: 0, appDataBytes: 0)) + } + func receivedArtifacts() async -> Result<[ReceivedArtifactModel], Error> { .success([]) } func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> { .success(requests[transferId] ?? []) } func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result { responses.append((requestId, accepted, reason)) diff --git a/apple/VniDrop/Core/CoreGateway.swift b/apple/VniDrop/Core/CoreGateway.swift index 73d921c..ee673f1 100644 --- a/apple/VniDrop/Core/CoreGateway.swift +++ b/apple/VniDrop/Core/CoreGateway.swift @@ -2,6 +2,16 @@ import Foundation import Combine import VnidropCore +struct CoreStorageUsageModel: Sendable { + let blobStoreBytes: UInt64 + let appDataBytes: UInt64 +} + +struct ReceivedArtifactModel: Sendable { + let locator: String + let logicalSize: UInt64 +} + /// Seam between the feature models and the Rust core, mirroring `CoreGateway` /// in the KMP `shared` module. `CoreRepository` is the production implementation; /// tests substitute a fake so the models can be exercised without the FFI. @@ -32,6 +42,8 @@ protocol CoreGateway: AnyObject { func cancel(transferId: UInt64) async -> Result func delete(transferId: UInt64) async -> Result func clearReceiveHistory() async -> Result + func storageUsage() async -> Result + func receivedArtifacts() async -> Result<[ReceivedArtifactModel], Error> func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result func refresh() async -> Result diff --git a/apple/VniDrop/Core/CoreRepository.swift b/apple/VniDrop/Core/CoreRepository.swift index c4895f6..4275d1e 100644 --- a/apple/VniDrop/Core/CoreRepository.swift +++ b/apple/VniDrop/Core/CoreRepository.swift @@ -143,6 +143,25 @@ final class CoreRepository: ObservableObject, CoreGateway { } } + func storageUsage() async -> Result { + await runCore { + let usage = try self.requireCore().storageUsage() + return CoreStorageUsageModel( + blobStoreBytes: usage.blobStoreBytes, + appDataBytes: usage.databaseBytes + usage.logsBytes + usage.previewsBytes + usage.otherCoreBytes + ) + } + } + + func receivedArtifacts() async -> Result<[ReceivedArtifactModel], Error> { + await runCore { + try self.requireCore().listReceivedArtifacts().compactMap { artifact in + guard artifact.locatorKind == .filesystemPath else { return nil } + return ReceivedArtifactModel(locator: artifact.locator, logicalSize: artifact.logicalSize) + } + } + } + func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> { await runCore { try self.requireCore().listReceiverRequests(transferId: transferId).map { $0.toModel() } diff --git a/apple/VniDrop/Features/Settings/SettingsModel.swift b/apple/VniDrop/Features/Settings/SettingsModel.swift index f237e4f..7f36840 100644 --- a/apple/VniDrop/Features/Settings/SettingsModel.swift +++ b/apple/VniDrop/Features/Settings/SettingsModel.swift @@ -27,9 +27,10 @@ enum SettingsSection: Hashable { /// On-disk usage breakdown for the Storage screen. struct StorageBreakdown: Equatable { var receivedFiles: UInt64 = 0 - var transferData: UInt64 = 0 + var transferCache: UInt64 = 0 + var appData: UInt64 = 0 var temporary: UInt64 = 0 - var total: UInt64 { receivedFiles + transferData + temporary } + var total: UInt64 { receivedFiles + transferCache + appData + temporary } } struct SettingsState: Equatable { @@ -286,19 +287,29 @@ final class SettingsModel: ObservableObject { func loadStorageUsage() { if state.isCalculatingStorage { return } state.isCalculatingStorage = true - let coreDir = environment.defaultCoreDataDir - let receiveDir = state.receiveFolder?.isFileSystemPath == true ? state.receiveFolder?.value : nil let tempDir = NSTemporaryDirectory() - Task.detached { - let breakdown = StorageBreakdown( - receivedFiles: receiveDir.map { SettingsModel.directorySize($0) } ?? 0, - transferData: SettingsModel.directorySize(coreDir), - temporary: SettingsModel.directorySize(tempDir) - ) - await MainActor.run { [weak self] in - self?.state.storage = breakdown - self?.state.isCalculatingStorage = false + Task { + let coreResult = await repository.storageUsage() + let artifactsResult = await repository.receivedArtifacts() + guard case .success(let core) = coreResult, + case .success(let artifacts) = artifactsResult else { + state.isCalculatingStorage = false + return } + let diskSizes = await Task.detached { + let received = artifacts.reduce(UInt64(0)) { total, artifact in + total + SettingsModel.fileSize(artifact.locator) + } + return (received, SettingsModel.directorySize(tempDir)) + }.value + let breakdown = StorageBreakdown( + receivedFiles: diskSizes.0, + transferCache: core.blobStoreBytes, + appData: core.appDataBytes, + temporary: diskSizes.1 + ) + state.storage = breakdown + state.isCalculatingStorage = false } } @@ -309,16 +320,29 @@ final class SettingsModel: ObservableObject { if state.isDeletingTransfers { return } state.isDeletingTransfers = true Task { + var failures = 0 for id in repository.state.transfers.map(\.transferId) { - _ = await repository.delete(transferId: id) + if case .failure = await repository.delete(transferId: id) { failures += 1 } } _ = await repository.refresh() state.isDeletingTransfers = false - loadStorageUsage() - messages.show(UiMessage(text: .resource("storage_transfers_deleted"), tone: .success)) + if failures == 0 { + loadStorageUsage() + messages.show(UiMessage(text: .resource("storage_transfers_deleted"), tone: .success)) + } else { + messages.error(InvitationError.message("Could not delete \(failures) transfer records")) + } } } + nonisolated static func fileSize(_ path: String) -> UInt64 { + let values = try? URL(fileURLWithPath: path).resourceValues( + forKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey] + ) + guard values?.isRegularFile == true else { return 0 } + return UInt64(values?.totalFileAllocatedSize ?? values?.fileSize ?? 0) + } + /// Recursive size of every regular file under `path` (0 if missing). nonisolated static func directorySize(_ path: String) -> UInt64 { let url = URL(fileURLWithPath: path) diff --git a/apple/VniDrop/Features/Settings/SettingsSections.swift b/apple/VniDrop/Features/Settings/SettingsSections.swift index b4f03f8..b055699 100644 --- a/apple/VniDrop/Features/Settings/SettingsSections.swift +++ b/apple/VniDrop/Features/Settings/SettingsSections.swift @@ -65,7 +65,8 @@ struct StorageSettings: View { Section { if let storage = model.state.storage { LabeledContent(String(localized: "storage_received_files"), value: formatBytes(storage.receivedFiles)) - LabeledContent(String(localized: "storage_transfer_data"), value: formatBytes(storage.transferData)) + LabeledContent(String(localized: "storage_transfer_data"), value: formatBytes(storage.transferCache)) + LabeledContent(String(localized: "storage_app_data"), value: formatBytes(storage.appData)) LabeledContent(String(localized: "storage_temporary"), value: formatBytes(storage.temporary)) LabeledContent(String(localized: "storage_total")) { Text(formatBytes(storage.total)).fontWeight(.semibold) diff --git a/apple/VniDrop/Resources/Localizable.xcstrings b/apple/VniDrop/Resources/Localizable.xcstrings index c35aa07..50f84eb 100644 --- a/apple/VniDrop/Resources/Localizable.xcstrings +++ b/apple/VniDrop/Resources/Localizable.xcstrings @@ -12009,6 +12009,66 @@ } } }, + "storage_app_data": { + "comment": "Settings > Storage: label for non-transfer application data.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "App-Daten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "App data" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Datos de la aplicación" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Données de l’app" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Dati dell’app" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Appgegevens" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Dane aplikacji" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Dados da aplicação" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Данные приложения" + } + } + } + }, "storage_calculating": { "comment": "Settings > Storage: placeholder while a size is being calculated.", "extractionState": "manual", @@ -12136,55 +12196,55 @@ "de": { "stringUnit": { "state": "needs_review", - "value": "Dies löscht alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf. Ihre empfangenen Dateien werden nicht gelöscht. Hinweis: Die Übertragungs-Engine behält ihre gespeicherten Inhalte, sodass die Übertragungsdaten möglicherweise nicht abnehmen. Dies kann nicht rückgängig gemacht werden." + "value": "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." } }, "en": { "stringUnit": { "state": "translated", - "value": "This clears all sent and received transfer records from your history. Your received files are not deleted. Note: the transfer engine keeps its stored content, so Transfer data may not decrease. This can’t be undone." + "value": "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." } }, "es": { "stringUnit": { "state": "needs_review", - "value": "Esto borra todos los registros de transferencias enviadas y recibidas de su historial. Sus archivos recibidos no se eliminan. Nota: el motor de transferencia conserva su contenido almacenado, por lo que los datos de transferencia pueden no disminuir. Esto no se puede deshacer." + "value": "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." } }, "fr": { "stringUnit": { "state": "needs_review", - "value": "Cela efface tous les enregistrements de transferts envoyés et reçus de votre historique. Vos fichiers reçus ne sont pas supprimés. Remarque : le moteur de transfert conserve son contenu stocké, donc les données de transfert peuvent ne pas diminuer. Cette action est irréversible." + "value": "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." } }, "it": { "stringUnit": { "state": "needs_review", - "value": "Questo cancella dalla cronologia tutti i record dei trasferimenti inviati e ricevuti. I suoi file ricevuti non vengono eliminati. Nota: il motore di trasferimento conserva il contenuto memorizzato, quindi i dati di trasferimento potrebbero non diminuire. Questa operazione non può essere annullata." + "value": "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." } }, "nl": { "stringUnit": { "state": "needs_review", - "value": "Hiermee worden alle records van verzonden en ontvangen overdrachten uit uw geschiedenis gewist. Uw ontvangen bestanden worden niet verwijderd. Let op: de overdrachtsengine bewaart de opgeslagen inhoud, dus de overdrachtsgegevens nemen mogelijk niet af. Dit kan niet ongedaan worden gemaakt." + "value": "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." } }, "pl": { "stringUnit": { "state": "needs_review", - "value": "To usuwa z historii wszystkie rekordy wysłanych i odebranych transferów. Twoje odebrane pliki nie są usuwane. Uwaga: silnik transferu zachowuje przechowywaną zawartość, więc dane transferu mogą się nie zmniejszyć. Tej operacji nie można cofnąć." + "value": "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ąć." } }, "pt": { "stringUnit": { "state": "needs_review", - "value": "Isto elimina do seu histórico todos os registos de transferências enviadas e recebidas. Os seus ficheiros recebidos não são eliminados. Nota: o motor de transferência mantém o conteúdo armazenado, pelo que os dados de transferência podem não diminuir. Isto não pode ser anulado." + "value": "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." } }, "ru": { "stringUnit": { "state": "needs_review", - "value": "Это удалит из истории все записи об отправленных и полученных передачах. Ваши полученные файлы не удаляются. Примечание: механизм передачи сохраняет своё содержимое, поэтому объём данных передачи может не уменьшиться. Это действие нельзя отменить." + "value": "Это удалит из истории все записи об отправленных и полученных передачах. Полученные файлы не удаляются. Кэшированное общее содержимое, которое больше не требуется, освобождается автоматически; это может занять некоторое время. Это действие нельзя отменить." } } } @@ -12256,55 +12316,55 @@ "de": { "stringUnit": { "state": "needs_review", - "value": "Übertragungsdaten werden von der Übertragungs-Engine verwaltet – Ihr Verlauf sowie der Inhalt der von Ihnen geteilten Dateien. Die Schaltfläche unten löscht Ihre Übertragungsdatensätze; die Engine behält ihre gespeicherten Inhalte, sodass dieser Wert möglicherweise nicht sinkt. Empfangene Dateien sind Ihre heruntergeladenen Dateien und werden hier niemals gelöscht." + "value": "Ü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." } }, "en": { "stringUnit": { "state": "translated", - "value": "Transfer data is managed by the transfer engine — your history plus the content of files you’ve shared. The button below clears your transfer records; the engine keeps its stored content, so this value may not drop. Received files are your downloaded files and are never deleted here." + "value": "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." } }, "es": { "stringUnit": { "state": "needs_review", - "value": "Los datos de transferencia los gestiona el motor de transferencia: su historial más el contenido de los archivos que ha compartido. El botón de abajo borra sus registros de transferencias; el motor conserva su contenido almacenado, por lo que este valor puede no bajar. Los archivos recibidos son los archivos que ha descargado y nunca se eliminan aquí." + "value": "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í." } }, "fr": { "stringUnit": { "state": "needs_review", - "value": "Les données de transfert sont gérées par le moteur de transfert — votre historique ainsi que le contenu des fichiers que vous avez partagés. Le bouton ci-dessous efface vos enregistrements de transferts ; le moteur conserve son contenu stocké, donc cette valeur peut ne pas baisser. Les fichiers reçus sont vos fichiers téléchargés et ne sont jamais supprimés ici." + "value": "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." } }, "it": { "stringUnit": { "state": "needs_review", - "value": "I dati di trasferimento sono gestiti dal motore di trasferimento: la sua cronologia più il contenuto dei file che ha condiviso. Il pulsante qui sotto cancella i record dei trasferimenti; il motore conserva il contenuto memorizzato, quindi questo valore potrebbe non diminuire. I file ricevuti sono i file che ha scaricato e non vengono mai eliminati qui." + "value": "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." } }, "nl": { "stringUnit": { "state": "needs_review", - "value": "Overdrachtsgegevens worden beheerd door de overdrachtsengine — uw geschiedenis plus de inhoud van de bestanden die u hebt gedeeld. De knop hieronder wist uw overdrachtsrecords; de engine bewaart de opgeslagen inhoud, dus deze waarde daalt mogelijk niet. Ontvangen bestanden zijn uw gedownloade bestanden en worden hier nooit verwijderd." + "value": "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." } }, "pl": { "stringUnit": { "state": "needs_review", - "value": "Danymi transferu zarządza silnik transferu — Twoja historia oraz zawartość udostępnionych plików. Przycisk poniżej usuwa rekordy transferów; silnik zachowuje przechowywaną zawartość, więc ta wartość może się nie zmniejszyć. Odebrane pliki to Twoje pobrane pliki i nigdy nie są tu usuwane." + "value": "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." } }, "pt": { "stringUnit": { "state": "needs_review", - "value": "Os dados de transferência são geridos pelo motor de transferência — o seu histórico mais o conteúdo dos ficheiros que partilhou. O botão abaixo elimina os seus registos de transferências; o motor mantém o conteúdo armazenado, pelo que este valor pode não descer. Os ficheiros recebidos são os ficheiros que descarregou e nunca são eliminados aqui." + "value": "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." } }, "ru": { "stringUnit": { "state": "needs_review", - "value": "Данными передачи управляет механизм передачи — ваша история плюс содержимое файлов, которыми вы поделились. Кнопка ниже удаляет записи о передачах; механизм сохраняет своё содержимое, поэтому это значение может не уменьшиться. Полученные файлы — это загруженные вами файлы, и они никогда не удаляются здесь." + "value": "Данные передачи включают историю и кэшированное содержимое активных раздач. Ненужный кэш освобождается автоматически после удаления записей; это может занять некоторое время. Полученные файлы учитываются в этой сводке, но никогда не удаляются здесь." } } } diff --git a/crates/vnidrop/CORE_FLOW.md b/crates/vnidrop/CORE_FLOW.md index 54ea299..6fa08e9 100644 --- a/crates/vnidrop/CORE_FLOW.md +++ b/crates/vnidrop/CORE_FLOW.md @@ -82,10 +82,14 @@ bytes through Kotlin memory. ## Blob Retention Policy Stopping a share immediately removes its provider mapping and approval state, -so outstanding VniDrop tickets can no longer download content. Physical blob chunks are -not force-deleted at stop time because content-addressed chunks may be shared by -another active collection. They remain eligible for the blob store's garbage -collection. Restart reconciliation never restores a stopped share. +so outstanding VniDrop tickets can no longer download content. Physical blob +chunks are not force-deleted at stop time because content-addressed chunks may be +shared by another active collection. Every active outgoing share has a persistent +`vnidrop/share/` tag; stopping or deleting it removes that tag, and the +configured garbage collector later reclaims content with no remaining persistent +or temporary tag. Receive downloads keep a temporary tag through export and become +reclaimable after publication. Restart reconciliation repairs active-share tags, +removes orphan share tags, and never restores a stopped share. ## Resource Limits diff --git a/crates/vnidrop/src/api.rs b/crates/vnidrop/src/api.rs index 6ce048e..e3b3c68 100644 --- a/crates/vnidrop/src/api.rs +++ b/crates/vnidrop/src/api.rs @@ -127,6 +127,60 @@ pub trait ReceiveOutputSink: Send + Sync { ) -> Result<(), crate::error::VnidropError>; } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] +pub enum ReceivedLocatorKind { + FilesystemPath, + AndroidMediaStore, + AndroidDocument, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] +pub struct PublishedOutput { + pub locator_kind: ReceivedLocatorKind, + pub locator: String, +} + +/// Versioned receive sink that reports the durable locator created at publish time. +#[uniffi::export(with_foreign)] +pub trait ReceiveOutputSinkV2: Send + Sync { + fn start_file(&self, relative_path: String) -> Result<(), crate::error::VnidropError>; + fn write_chunk( + &self, + relative_path: String, + bytes: Vec, + ) -> Result<(), crate::error::VnidropError>; + fn finish_file( + &self, + relative_path: String, + ) -> Result; + fn abort_file( + &self, + relative_path: String, + reason: String, + ) -> Result<(), crate::error::VnidropError>; +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] +pub struct ReceivedArtifact { + pub id: String, + pub transfer_local_id: String, + pub protocol_transfer_id: u64, + pub relative_path: String, + pub locator_kind: ReceivedLocatorKind, + pub locator: String, + pub logical_size: u64, + pub published_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] +pub struct CoreStorageUsage { + pub blob_store_bytes: u64, + pub database_bytes: u64, + pub logs_bytes: u64, + pub previews_bytes: u64, + pub other_core_bytes: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct RuntimeStatus { pub endpoint_id: String, diff --git a/crates/vnidrop/src/filesystem.rs b/crates/vnidrop/src/filesystem.rs index c23b3a9..6524c78 100644 --- a/crates/vnidrop/src/filesystem.rs +++ b/crates/vnidrop/src/filesystem.rs @@ -109,6 +109,10 @@ impl AtomicOutputFile { self.committed = true; Ok(()) } + + pub(crate) fn target(&self) -> &Path { + &self.target + } } /// Publish a fully written temporary file as the final destination without diff --git a/crates/vnidrop/src/lib.rs b/crates/vnidrop/src/lib.rs index d6bce50..f958b5b 100644 --- a/crates/vnidrop/src/lib.rs +++ b/crates/vnidrop/src/lib.rs @@ -14,7 +14,8 @@ mod transfer_state; mod util; pub use api::{ - default_core_limits, CoreEvent, CoreEventSink, CoreLimits, ReceiveOutputSink, ReceiverRequest, + default_core_limits, CoreEvent, CoreEventSink, CoreLimits, CoreStorageUsage, PublishedOutput, + ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata, }; diff --git a/crates/vnidrop/src/repository.rs b/crates/vnidrop/src/repository.rs index fedd924..2bb71e2 100644 --- a/crates/vnidrop/src/repository.rs +++ b/crates/vnidrop/src/repository.rs @@ -15,12 +15,12 @@ use uuid::Uuid; use crate::{ access_policy::mode_from_storage, - api::{CoreEvent, ReceiverRequest, StoredTransfer}, + api::{CoreEvent, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, StoredTransfer}, transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus}, util::now_ms, }; -const SCHEMA_VERSION: i64 = 4; +const SCHEMA_VERSION: i64 = 5; #[derive(Debug, Clone)] pub(crate) struct Repository { @@ -47,6 +47,7 @@ pub(crate) struct TransferUpsert<'a> { #[derive(Debug, Clone)] pub(crate) struct PersistedShare { pub(crate) transfer_id: u64, + pub(crate) local_id: String, pub(crate) content_hash: String, pub(crate) access_mode: String, } @@ -58,6 +59,15 @@ pub(crate) struct RecoveredTransfer { pub(crate) previous_status: TransferStatus, } +pub(crate) struct ReceivedArtifactInsert<'a> { + pub(crate) transfer_local_id: &'a str, + pub(crate) protocol_transfer_id: u64, + pub(crate) relative_path: &'a str, + pub(crate) locator_kind: ReceivedLocatorKind, + pub(crate) locator: &'a str, + pub(crate) logical_size: u64, +} + pub(crate) struct ReceiverRequestInsert<'a> { pub(crate) id: &'a str, pub(crate) transfer_id: u64, @@ -169,6 +179,24 @@ impl Repository { .execute(&self.pool) .await?; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS received_artifacts ( + id TEXT PRIMARY KEY, + transfer_local_id TEXT NOT NULL, + protocol_transfer_id INTEGER NOT NULL, + relative_path TEXT NOT NULL, + locator_kind TEXT NOT NULL, + locator TEXT NOT NULL, + logical_size INTEGER NOT NULL, + published_at INTEGER NOT NULL, + UNIQUE(transfer_local_id, relative_path) + ); + "#, + ) + .execute(&self.pool) + .await?; + sqlx::query( r#" CREATE TABLE IF NOT EXISTS transfer_events ( @@ -330,6 +358,71 @@ impl Repository { Ok(()) } + pub(crate) async fn transfer_local_id(&self, transfer_id: u64) -> Result { + let row = sqlx::query("SELECT local_id FROM transfers WHERE transfer_id = ?1") + .bind(to_db_id(transfer_id)?) + .fetch_one(&self.pool) + .await?; + Ok(row.get(0)) + } + + pub(crate) async fn record_received_artifact( + &self, + artifact: ReceivedArtifactInsert<'_>, + ) -> Result<()> { + sqlx::query( + r#" + INSERT INTO received_artifacts ( + id, transfer_local_id, protocol_transfer_id, relative_path, + locator_kind, locator, logical_size, published_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(transfer_local_id, relative_path) DO UPDATE SET + locator_kind = excluded.locator_kind, + locator = excluded.locator, + logical_size = excluded.logical_size, + published_at = excluded.published_at + "#, + ) + .bind(Uuid::new_v4().to_string()) + .bind(artifact.transfer_local_id) + .bind(to_db_id(artifact.protocol_transfer_id)?) + .bind(artifact.relative_path) + .bind(locator_kind_to_storage(&artifact.locator_kind)) + .bind(artifact.locator) + .bind(to_db_id(artifact.logical_size)?) + .bind(now_ms()) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub(crate) async fn list_received_artifacts(&self) -> Result> { + let rows = sqlx::query( + r#" + SELECT id, transfer_local_id, protocol_transfer_id, relative_path, + locator_kind, locator, logical_size, published_at + FROM received_artifacts + ORDER BY published_at DESC + "#, + ) + .fetch_all(&self.pool) + .await?; + rows.into_iter() + .map(|row| { + Ok(ReceivedArtifact { + id: row.get("id"), + transfer_local_id: row.get("transfer_local_id"), + protocol_transfer_id: row.get::("protocol_transfer_id") as u64, + relative_path: row.get("relative_path"), + locator_kind: locator_kind_from_storage(&row.get::("locator_kind"))?, + locator: row.get("locator"), + logical_size: row.get::("logical_size") as u64, + published_at: row.get("published_at"), + }) + }) + .collect() + } + pub(crate) async fn complete_share_import(&self, transfer: TransferUpsert<'_>) -> Result<()> { self.maybe_fail_write()?; if transfer.direction != TransferDirection::Send @@ -508,7 +601,7 @@ impl Repository { pub(crate) async fn list_active_shares(&self) -> Result> { let rows = sqlx::query( r#" - SELECT transfer_id, content_hash, access_mode + SELECT transfer_id, local_id, content_hash, access_mode FROM transfers WHERE direction = 'send' AND status = 'sharing' @@ -521,8 +614,9 @@ impl Repository { .into_iter() .map(|row| PersistedShare { transfer_id: row.get::(0) as u64, - content_hash: row.get::(1), - access_mode: row.get::(2), + local_id: row.get::(1), + content_hash: row.get::(2), + access_mode: row.get::(3), }) .collect()) } @@ -870,6 +964,23 @@ fn to_db_id(value: u64) -> Result { i64::try_from(value).context("transfer id exceeds SQLite signed integer range") } +fn locator_kind_to_storage(kind: &ReceivedLocatorKind) -> &'static str { + match kind { + ReceivedLocatorKind::FilesystemPath => "filesystem_path", + ReceivedLocatorKind::AndroidMediaStore => "android_media_store", + ReceivedLocatorKind::AndroidDocument => "android_document", + } +} + +fn locator_kind_from_storage(value: &str) -> Result { + match value { + "filesystem_path" => Ok(ReceivedLocatorKind::FilesystemPath), + "android_media_store" => Ok(ReceivedLocatorKind::AndroidMediaStore), + "android_document" => Ok(ReceivedLocatorKind::AndroidDocument), + _ => anyhow::bail!("unknown received artifact locator kind: {value}"), + } +} + fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> Result { let direction = row.get::("direction"); let status = row.get::("status"); diff --git a/crates/vnidrop/src/runtime/facade.rs b/crates/vnidrop/src/runtime/facade.rs index 1519440..26b1a1d 100644 --- a/crates/vnidrop/src/runtime/facade.rs +++ b/crates/vnidrop/src/runtime/facade.rs @@ -6,9 +6,9 @@ use serde_json::json; use super::CoreInner; use crate::{ api::{ - CoreEvent, CoreEventSink, CoreLimits, ReceiveOutputSink, ReceiverRequest, RuntimeStatus, - ShareMetadataInput, ShareResult, ShareSource, StoredTransfer, TicketInspection, - TransferAccessMode, + CoreEvent, CoreEventSink, CoreLimits, CoreStorageUsage, ReceiveOutputSink, + ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus, ShareMetadataInput, + ShareResult, ShareSource, StoredTransfer, TicketInspection, TransferAccessMode, }, error::VnidropError, filesystem::platform_path, @@ -126,6 +126,24 @@ impl VnidropCore { .map_err(VnidropError::transfer) } + pub fn receive_with_output_sink_v2( + &self, + ticket: String, + output_sink: Arc, + receiver_name: Option, + ) -> Result<(), VnidropError> { + if let Err(error) = parse_transfer_ticket_with_limits(&ticket, &self.inner.limits) + .context("failed to parse transfer ticket") + { + return Err(VnidropError::ticket(error)); + } + self.block_on( + self.inner + .receive_with_output_sink_v2(ticket, output_sink, receiver_name), + ) + .map_err(VnidropError::transfer) + } + pub fn cancel_transfer(&self, transfer_id: u64) -> Result<(), VnidropError> { // Fire the oneshot on this thread before entering the runtime so a // blocked export write cannot prevent the cancel signal from being @@ -214,6 +232,16 @@ impl VnidropCore { .map_err(VnidropError::repository) } + pub fn list_received_artifacts(&self) -> Result, VnidropError> { + self.block_on(self.inner.repository.list_received_artifacts()) + .map_err(VnidropError::repository) + } + + pub fn storage_usage(&self) -> Result { + self.block_on(self.inner.storage_usage()) + .map_err(VnidropError::filesystem) + } + pub fn list_events(&self, transfer_id: Option) -> Result, VnidropError> { self.block_on(self.inner.list_events(transfer_id)) .map_err(VnidropError::repository) diff --git a/crates/vnidrop/src/runtime/lifecycle.rs b/crates/vnidrop/src/runtime/lifecycle.rs index 14ae84e..2d26bb8 100644 --- a/crates/vnidrop/src/runtime/lifecycle.rs +++ b/crates/vnidrop/src/runtime/lifecycle.rs @@ -3,7 +3,7 @@ use std::sync::atomic::Ordering; use anyhow::Result; use serde_json::json; -use super::CoreInner; +use super::{share_tag_name, CoreInner}; use crate::{ access_policy::mode_to_storage, api::{RuntimeStatus, TransferAccessMode}, @@ -42,6 +42,7 @@ impl CoreInner { pub(super) async fn cancel_idle_or_share(&self, transfer_id: u64) -> Result<()> { let mut active_shares = self.active_shares.lock().await; if active_shares.contains_key(&transfer_id) { + let local_id = self.repository.transfer_local_id(transfer_id).await?; self.repository .transition_transfer_status( transfer_id, @@ -53,6 +54,7 @@ impl CoreInner { drop(active_shares); self.unregister_transfer_hashes(transfer_id).await; self.access_policy.remove_transfer(transfer_id).await; + self.store.tags().delete(share_tag_name(&local_id)).await?; self.emit_transfer(transfer_id, "send", "lifecycle", "share-stopped", json!({})); return Ok(()); } @@ -105,6 +107,12 @@ impl CoreInner { self.active_shares.lock().await.remove(&transfer_id); self.unregister_transfer_hashes(transfer_id).await; self.access_policy.remove_transfer(transfer_id).await; + if transfer.direction == TransferDirection::Send.as_str() { + self.store + .tags() + .delete(share_tag_name(&transfer.local_id)) + .await?; + } // Events are persisted asynchronously. Drain events emitted before this // request so none can be written back after the transfer is deleted. self.event_hub.flush().await; diff --git a/crates/vnidrop/src/runtime/mod.rs b/crates/vnidrop/src/runtime/mod.rs index 75d8e05..5114634 100644 --- a/crates/vnidrop/src/runtime/mod.rs +++ b/crates/vnidrop/src/runtime/mod.rs @@ -12,6 +12,7 @@ mod lifecycle; mod provider; mod receive; mod share; +mod storage; pub use facade::VnidropCore; @@ -20,15 +21,19 @@ use std::{ path::PathBuf, str::FromStr, sync::{atomic::AtomicBool, Arc}, + time::Duration, }; use anyhow::Result; +use futures_lite::StreamExt as _; use iroh::{endpoint::presets, protocol::Router, Endpoint}; use iroh_blobs::{ - api::TempTag, format::collection::Collection, provider::events::{EventMask, EventSender}, - store::fs::FsStore, + store::{ + fs::{options::Options as FsStoreOptions, FsStore}, + GcConfig, + }, BlobsProtocol, Hash, }; use serde_json::json; @@ -52,6 +57,7 @@ use crate::{ /// Owns the Iroh endpoint, blob store, transfer history, and byte streaming. /// Kotlin owns app lifecycle and platform file picking. pub(super) struct CoreInner { + pub(super) app_data_dir: PathBuf, pub(super) endpoint: Endpoint, pub(super) router: Router, pub(super) store: FsStore, @@ -64,10 +70,8 @@ pub(super) struct CoreInner { /// Sync mutex so cancel can remove + signal without awaiting (and without /// holding a Tokio lock across repository I/O). pub(super) active_transfers: std::sync::Mutex>, - // Newly imported shares retain a TempTag for the lifetime of this process. - // Restored shares have no in-memory tag, but remain tracked so they can be - // counted and explicitly revoked after a restart. - pub(super) active_shares: TokioMutex>>, + // Active shares are protected by persistent Iroh tags. + pub(super) active_shares: TokioMutex>, /// Content hash → active share transfer ids (root and collection members). /// Multiple transfers can share the same content-addressed hash. pub(super) hash_to_transfer: TokioMutex>>, @@ -92,7 +96,12 @@ impl CoreInner { let secret_key = load_or_create_secret(&app_data_dir).await?; let repository = Repository::open(&app_data_dir).await?; let store_root = app_data_dir.join("blobs"); - let store = FsStore::load(&store_root).await?; + let mut store_options = FsStoreOptions::new(&store_root); + store_options.gc = Some(GcConfig { + interval: Duration::from_secs(30 * 60), + add_protected: None, + }); + let store = FsStore::load_with_opts(store_root.join("blobs.db"), store_options).await?; let endpoint = Endpoint::builder(presets::N0) .secret_key(secret_key) .bind() @@ -135,6 +144,7 @@ impl CoreInner { // Register root + every collection member so child gets stay under ACL. let mut restored_hashes: HashMap> = HashMap::new(); let mut restored_active_shares = HashMap::new(); + let mut active_tag_names = HashSet::new(); for share in repository.list_active_shares().await? { let transfer_id = share.transfer_id; let Ok(root_hash) = Hash::from_str(&share.content_hash) else { @@ -176,6 +186,12 @@ impl CoreInner { ); continue; }; + let tag_name = share_tag_name(&share.local_id); + store + .tags() + .set(&tag_name, (root_hash, iroh_blobs::BlobFormat::HashSeq)) + .await?; + active_tag_names.insert(tag_name); restored_hashes .entry(root_hash.to_string()) .or_default() @@ -186,11 +202,19 @@ impl CoreInner { .or_default() .insert(transfer_id); } - restored_active_shares.insert(transfer_id, None); + restored_active_shares.insert(transfer_id, ()); access_policy .set_mode(transfer_id, mode_from_storage(&share.access_mode)) .await; } + let mut share_tags = store.tags().list_prefix("vnidrop/share/").await?; + while let Some(tag) = share_tags.next().await { + let tag = tag?; + let name = String::from_utf8_lossy(tag.name.as_ref()).to_string(); + if !active_tag_names.contains(&name) { + store.tags().delete(name).await?; + } + } let approval = ApprovalService::new( repository.clone(), event_hub.clone(), @@ -205,6 +229,7 @@ impl CoreInner { .spawn(); let inner = Arc::new(Self { + app_data_dir, endpoint, router, store, @@ -277,3 +302,7 @@ impl CoreInner { .await } } + +pub(super) fn share_tag_name(local_id: &str) -> String { + format!("vnidrop/share/{local_id}") +} diff --git a/crates/vnidrop/src/runtime/receive.rs b/crates/vnidrop/src/runtime/receive.rs index 6ee8878..8121bce 100644 --- a/crates/vnidrop/src/runtime/receive.rs +++ b/crates/vnidrop/src/runtime/receive.rs @@ -17,13 +17,16 @@ use tokio::sync::oneshot; use super::{ActiveTransfer, CoreInner}; use crate::{ access_policy::mode_to_storage, - api::{ReceiveOutputSink, TransferAccessMode, TransferMetadata}, + api::{ + ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedLocatorKind, TransferAccessMode, + TransferMetadata, + }, filesystem::{ validated_relative_string, wait_for_writer, write_stream_to_blocking_writer, AtomicOutputFile, }, handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, HandshakeService}, - repository::TransferUpsert, + repository::{ReceivedArtifactInsert, TransferUpsert}, ticket::{parse_transfer_ticket_with_limits, ParsedTransferTicket}, transfer_state::{TransferDirection, TransferStatus}, }; @@ -31,6 +34,13 @@ use crate::{ pub(super) enum ReceiveTarget { Directory(PathBuf), OutputSink(Arc), + OutputSinkV2(Arc), +} + +#[derive(Clone, Copy)] +struct ReceivedTransfer<'a> { + protocol_id: u64, + local_id: &'a str, } pub(super) struct OutputSinkFile<'a> { @@ -85,6 +95,51 @@ impl Drop for OutputSinkFile<'_> { } } +pub(super) struct OutputSinkFileV2<'a> { + sink: &'a dyn ReceiveOutputSinkV2, + relative_path: String, + terminal: bool, +} + +impl<'a> OutputSinkFileV2<'a> { + fn start(sink: &'a dyn ReceiveOutputSinkV2, relative_path: String) -> Result { + sink.start_file(relative_path.clone()) + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + Ok(Self { + sink, + relative_path, + terminal: false, + }) + } + + fn write(&self, bytes: Vec) -> Result<()> { + self.sink + .write_chunk(self.relative_path.clone(), bytes) + .map_err(|error| anyhow::anyhow!(error.to_string())) + } + + fn finish(mut self) -> Result { + self.terminal = true; + self.sink + .finish_file(self.relative_path.clone()) + .map_err(|error| anyhow::anyhow!(error.to_string())) + } +} + +impl Drop for OutputSinkFileV2<'_> { + fn drop(&mut self) { + if !self.terminal { + self.terminal = true; + if let Err(error) = self.sink.abort_file( + self.relative_path.clone(), + "transfer interrupted before file completion".to_string(), + ) { + tracing::warn!(%error, relative_path = %self.relative_path, "failed to abort receive output sink file"); + } + } + } +} + impl CoreInner { pub(super) async fn receive( self: &Arc, @@ -110,6 +165,20 @@ impl CoreInner { .await } + pub(super) async fn receive_with_output_sink_v2( + self: &Arc, + ticket: String, + output_sink: Arc, + receiver_name: Option, + ) -> Result<()> { + self.receive_to_target( + ticket, + ReceiveTarget::OutputSinkV2(output_sink), + receiver_name, + ) + .await + } + pub(super) async fn receive_to_target( self: &Arc, ticket: String, @@ -242,9 +311,15 @@ impl CoreInner { json!({ "total_files": total_files, "total_size": total_size }), ); + // Protect both partial download state and the completed collection until + // every output has been published and recorded. + let download_tag = self.store.tags().temp_tag(hash_and_format).await?; let get = self.store.remote().fetch(connection, hash_and_format); let mut stream = get.stream(); - while let Some(item) = stream.next().await { + loop { + let Some(item) = stream.next().await else { + anyhow::bail!("download ended without completion"); + }; match item { GetProgressItem::Progress(downloaded) => { self.emit_transfer( @@ -270,6 +345,7 @@ impl CoreInner { TransferStatus::Done, ) .await?; + drop(download_tag); self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({})); let sender_transfer_id = delivery_receipt.transfer_id; let client = HandshakeService::client(self.endpoint.clone(), sender_addr); @@ -393,11 +469,16 @@ impl CoreInner { target: ReceiveTarget, collection: Collection, ) -> Result<()> { + let transfer_local_id = self.repository.transfer_local_id(transfer_id).await?; + let received_transfer = ReceivedTransfer { + protocol_id: transfer_id, + local_id: &transfer_local_id, + }; for (i, (name, hash)) in collection.iter().enumerate() { match &target { ReceiveTarget::Directory(output_dir) => { self.export_blob_to_directory( - transfer_id, + received_transfer, total_files, i as u64, output_dir, @@ -417,14 +498,25 @@ impl CoreInner { ) .await?; } + ReceiveTarget::OutputSinkV2(output_sink) => { + self.export_blob_to_sink_v2( + received_transfer, + total_files, + i as u64, + output_sink.as_ref(), + name.as_ref(), + *hash, + ) + .await?; + } } } Ok(()) } - pub(super) async fn export_blob_to_directory( + async fn export_blob_to_directory( &self, - transfer_id: u64, + transfer: ReceivedTransfer<'_>, total_files: u64, current_file_index: u64, output_dir: &Path, @@ -458,7 +550,7 @@ impl CoreInner { .await .map_err(|_| anyhow::anyhow!("export writer closed for {relative_path}"))?; self.emit_transfer( - transfer_id, + transfer.protocol_id, "receive", "export", "progress", @@ -482,7 +574,18 @@ impl CoreInner { .map_err(|_| anyhow::anyhow!("export writer closed for {relative_path}"))?; let writer = wait_for_writer(writer_task).await??; tokio::task::spawn_blocking(move || writer.sync_all()).await??; + let locator = pending_file.target().to_string_lossy().to_string(); pending_file.commit()?; + self.repository + .record_received_artifact(ReceivedArtifactInsert { + transfer_local_id: transfer.local_id, + protocol_transfer_id: transfer.protocol_id, + relative_path, + locator_kind: ReceivedLocatorKind::FilesystemPath, + locator: &locator, + logical_size: exported, + }) + .await?; Ok(()) } @@ -545,4 +648,71 @@ impl CoreInner { output_file.finish()?; Ok(()) } + + async fn export_blob_to_sink_v2( + &self, + transfer: ReceivedTransfer<'_>, + total_files: u64, + current_file_index: u64, + output_sink: &dyn ReceiveOutputSinkV2, + relative_path: &str, + hash: Hash, + ) -> Result<()> { + if relative_path.len() as u64 > self.limits.max_path_bytes { + anyhow::bail!( + "output path exceeds {} bytes: {relative_path}", + self.limits.max_path_bytes + ); + } + let relative_path = validated_relative_string(relative_path)?; + let output_file = OutputSinkFileV2::start(output_sink, relative_path.clone())?; + let mut stream = self.store.export_ranges(hash, 0..u64::MAX).stream(); + let mut file_size = 0; + let mut exported = 0; + + while let Some(item) = stream.next().await { + match item { + ExportRangesItem::Size(size) => file_size = size, + ExportRangesItem::Data(leaf) => { + if leaf.offset != exported { + anyhow::bail!( + "export stream for {relative_path} yielded out-of-order data" + ); + } + exported += leaf.data.len() as u64; + output_file.write(leaf.data.to_vec())?; + self.emit_transfer( + transfer.protocol_id, + "receive", + "export", + "progress", + json!({ + "total_files": total_files, + "current_file_index": current_file_index, + "file_name": relative_path, + "file_size": file_size, + "exported": exported, + }), + ); + tokio::task::yield_now().await; + } + ExportRangesItem::Error(error) => { + anyhow::bail!("export failed for {relative_path}: {error}"); + } + } + } + + let published = output_file.finish()?; + self.repository + .record_received_artifact(ReceivedArtifactInsert { + transfer_local_id: transfer.local_id, + protocol_transfer_id: transfer.protocol_id, + relative_path: &relative_path, + locator_kind: published.locator_kind, + locator: &published.locator, + logical_size: exported, + }) + .await?; + Ok(()) + } } diff --git a/crates/vnidrop/src/runtime/share.rs b/crates/vnidrop/src/runtime/share.rs index 51e8bc7..56e4511 100644 --- a/crates/vnidrop/src/runtime/share.rs +++ b/crates/vnidrop/src/runtime/share.rs @@ -12,7 +12,7 @@ use n0_future::BufferedStreamExt; use serde_json::json; use tokio::sync::oneshot; -use super::{ActiveTransfer, CoreInner}; +use super::{share_tag_name, ActiveTransfer, CoreInner}; use crate::{ access_policy::mode_to_storage, api::TransferMetadata, @@ -142,11 +142,24 @@ impl CoreInner { .encode() .context("failed to encode VniDrop transfer ticket")?; let content_hash = import.root_hash.to_string(); + let local_id = self + .repository + .transfer_local_id(metadata.transfer_id) + .await?; + let tag_name = share_tag_name(&local_id); + self.store + .tags() + .set( + &tag_name, + (import.root_hash, iroh_blobs::BlobFormat::HashSeq), + ) + .await?; // Persist the completed share before exposing it through the provider. // The remaining in-memory registrations are infallible and can be // reconstructed from SQLite if the process exits immediately after. - self.repository + if let Err(error) = self + .repository .complete_share_import(TransferUpsert { transfer_id: metadata.transfer_id, peer_id: None, @@ -159,7 +172,11 @@ impl CoreInner { total_size: import.total_size, access_mode: mode_to_storage(&access_mode), }) - .await?; + .await + { + let _ = self.store.tags().delete(&tag_name).await; + return Err(error); + } // Map root + every collection member so provider ACL cannot fail-open // on child blob hashes that are not the collection root. self.register_share_hashes( @@ -173,7 +190,8 @@ impl CoreInner { self.active_shares .lock() .await - .insert(metadata.transfer_id, Some(import.tag)); + .insert(metadata.transfer_id, ()); + drop(import.tag); // Tickets are capabilities: never persist the full string in events. self.emit_transfer( diff --git a/crates/vnidrop/src/runtime/storage.rs b/crates/vnidrop/src/runtime/storage.rs new file mode 100644 index 0000000..a648a81 --- /dev/null +++ b/crates/vnidrop/src/runtime/storage.rs @@ -0,0 +1,69 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Result; + +use super::CoreInner; +use crate::api::CoreStorageUsage; + +impl CoreInner { + pub(super) async fn storage_usage(&self) -> Result { + let app_data_dir = self.app_data_dir.clone(); + tokio::task::spawn_blocking(move || scan_storage(&app_data_dir)).await? + } +} + +fn scan_storage(app_data_dir: &Path) -> Result { + let blob_store_bytes = directory_size(&app_data_dir.join("blobs"))?; + let logs_bytes = directory_size(&app_data_dir.join("logs"))?; + let previews_bytes = directory_size(&app_data_dir.join("ui").join("previews"))?; + let database_bytes = [ + "vnidrop.sqlite3", + "vnidrop.sqlite3-wal", + "vnidrop.sqlite3-shm", + ] + .into_iter() + .try_fold(0u64, |total, name| { + Ok::<_, std::io::Error>(total.saturating_add(file_size(&app_data_dir.join(name))?)) + })?; + let total = directory_size(app_data_dir)?; + let classified = blob_store_bytes + .saturating_add(logs_bytes) + .saturating_add(previews_bytes) + .saturating_add(database_bytes); + Ok(CoreStorageUsage { + blob_store_bytes, + database_bytes, + logs_bytes, + previews_bytes, + other_core_bytes: total.saturating_sub(classified), + }) +} + +fn directory_size(path: &Path) -> Result { + if !path.exists() { + return Ok(0); + } + let mut total = 0u64; + let mut pending = vec![PathBuf::from(path)]; + while let Some(directory) = pending.pop() { + for entry in std::fs::read_dir(directory)? { + let entry = entry?; + let metadata = entry.metadata()?; + if metadata.is_dir() { + pending.push(entry.path()); + } else if metadata.is_file() { + total = total.saturating_add(metadata.len()); + } + } + } + Ok(total) +} + +fn file_size(path: &Path) -> std::io::Result { + match std::fs::metadata(path) { + Ok(metadata) if metadata.is_file() => Ok(metadata.len()), + Ok(_) => Ok(0), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(0), + Err(error) => Err(error), + } +} diff --git a/crates/vnidrop/src/tests/repository.rs b/crates/vnidrop/src/tests/repository.rs index 63adcbf..3edb411 100644 --- a/crates/vnidrop/src/tests/repository.rs +++ b/crates/vnidrop/src/tests/repository.rs @@ -1,6 +1,6 @@ use crate::{ - api::CoreEvent, - repository::{ReceiverRequestInsert, Repository, TransferUpsert}, + api::{CoreEvent, ReceivedLocatorKind}, + repository::{ReceivedArtifactInsert, ReceiverRequestInsert, Repository, TransferUpsert}, transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus}, }; @@ -23,11 +23,47 @@ fn transfer( } } +#[tokio::test] +async fn received_artifacts_survive_history_deletion() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .start_receive(transfer( + 91, + TransferDirection::Receive, + TransferStatus::Receiving, + )) + .await + .unwrap(); + let local_id = repository.transfer_local_id(91).await.unwrap(); + repository + .record_received_artifact(ReceivedArtifactInsert { + transfer_local_id: &local_id, + protocol_transfer_id: 91, + relative_path: "folder/file.txt", + locator_kind: ReceivedLocatorKind::FilesystemPath, + locator: "/tmp/folder/file.txt", + logical_size: 12, + }) + .await + .unwrap(); + repository + .transition_transfer_status(91, TransferStatus::Receiving, TransferStatus::Done) + .await + .unwrap(); + + assert_eq!(repository.delete_receive_history().await.unwrap(), 1); + let artifacts = repository.list_received_artifacts().await.unwrap(); + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0].transfer_local_id, local_id); + assert_eq!(artifacts[0].logical_size, 12); +} + #[tokio::test] async fn persists_transfers_and_events_across_reopen() { let temp = tempfile::tempdir().unwrap(); let repository = Repository::open(temp.path()).await.unwrap(); - assert_eq!(repository.schema_version().await.unwrap(), 4); + assert_eq!(repository.schema_version().await.unwrap(), 5); repository .insert_transfer(transfer( 7, @@ -491,7 +527,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() { pool.close().await; let repository = Repository::open(temp.path()).await.unwrap(); - assert_eq!(repository.schema_version().await.unwrap(), 4); + assert_eq!(repository.schema_version().await.unwrap(), 5); let stored = repository.list_transfers().await.unwrap().remove(0); assert_eq!(stored.transfer_id, 7); assert_eq!(stored.local_id, "legacy-7-send"); diff --git a/crates/vnidrop/tests/lifecycle.rs b/crates/vnidrop/tests/lifecycle.rs index a6bdd5f..4637e7d 100644 --- a/crates/vnidrop/tests/lifecycle.rs +++ b/crates/vnidrop/tests/lifecycle.rs @@ -3,6 +3,8 @@ mod support; use std::sync::{Arc, Condvar, Mutex}; use std::time::Duration; +use futures_lite::StreamExt as _; +use iroh_blobs::store::fs::FsStore; use support::{share_path, CoreGuard, RecordingSink, TestNode}; use vnidrop::{ CoreEvent, CoreEventSink, CoreLimits, ShareMetadataInput, ShareSource, SourceKind, @@ -77,6 +79,39 @@ fn deleting_share_revokes_it_and_removes_persisted_history() { assert_eq!(restarted.status().active_shares, 0); } +#[test] +fn active_share_tag_is_persistent_and_removed_with_transfer() { + let source_dir = tempfile::tempdir().unwrap(); + let core_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("tagged.txt"); + std::fs::write(&source_path, b"tagged content").unwrap(); + + let sender = CoreGuard::start(core_dir.path(), Arc::new(RecordingSink::default())); + let share = share_path(&sender, &source_path, 102, "tagged.txt", false); + drop(sender); + assert_eq!(share_tag_count(core_dir.path()), 1); + + let restarted = CoreGuard::start(core_dir.path(), Arc::new(RecordingSink::default())); + restarted.delete_transfer(share.transfer_id).unwrap(); + drop(restarted); + assert_eq!(share_tag_count(core_dir.path()), 0); +} + +fn share_tag_count(core_dir: &std::path::Path) -> usize { + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async { + let store = FsStore::load(core_dir.join("blobs")).await.unwrap(); + let mut tags = store.tags().list_prefix("vnidrop/share/").await.unwrap(); + let mut count = 0; + while let Some(tag) = tags.next().await { + tag.unwrap(); + count += 1; + } + store.shutdown().await.unwrap(); + count + }) +} + #[test] fn persisted_share_is_recovered_and_can_be_stopped_after_restart() { let source_dir = tempfile::tempdir().unwrap(); diff --git a/crates/vnidrop/tests/output_sink.rs b/crates/vnidrop/tests/output_sink.rs index 1b01319..6f0c2ed 100644 --- a/crates/vnidrop/tests/output_sink.rs +++ b/crates/vnidrop/tests/output_sink.rs @@ -5,9 +5,36 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use support::{ - receive_with_sink_response, share_path, wait_for_receiver_request, MemoryOutputSink, TestNode, + receive_with_sink_response, receive_with_sink_v2_response, share_path, + wait_for_receiver_request, MemoryOutputSink, TestNode, }; +#[test] +fn versioned_sink_records_published_locator() { + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("tracked.txt"); + std::fs::write(&source_path, b"tracked").unwrap(); + let sender = TestNode::new(); + let receiver = TestNode::new(); + let share = share_path(&sender.core, &source_path, 38, "tracked.txt", false); + let output_sink = Arc::new(MemoryOutputSink::default()); + + receive_with_sink_v2_response( + &sender.core, + share.transfer_id, + receiver.core.arc(), + share.ticket, + output_sink, + true, + ) + .unwrap(); + + let artifacts = receiver.core.list_received_artifacts().unwrap(); + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0].locator, "content://test/tracked.txt"); + assert_eq!(artifacts[0].logical_size, 7); +} + #[test] fn exports_nested_files_to_output_sink() { let source_dir = tempfile::tempdir().unwrap(); diff --git a/crates/vnidrop/tests/support/mod.rs b/crates/vnidrop/tests/support/mod.rs index a820e50..f628697 100644 --- a/crates/vnidrop/tests/support/mod.rs +++ b/crates/vnidrop/tests/support/mod.rs @@ -14,8 +14,9 @@ use std::{ }; use vnidrop::{ - CoreEvent, CoreEventSink, CoreLimits, ReceiveOutputSink, ReceiverRequest, ShareMetadataInput, - ShareResult, ShareSource, SourceKind, TransferAccessMode, VnidropCore, VnidropError, + CoreEvent, CoreEventSink, CoreLimits, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2, + ReceivedLocatorKind, ReceiverRequest, ShareMetadataInput, ShareResult, ShareSource, SourceKind, + TransferAccessMode, VnidropCore, VnidropError, }; #[derive(Default)] @@ -219,6 +220,28 @@ impl ReceiveOutputSink for MemoryOutputSink { } } +impl ReceiveOutputSinkV2 for MemoryOutputSink { + fn start_file(&self, relative_path: String) -> Result<(), VnidropError> { + ReceiveOutputSink::start_file(self, relative_path) + } + + fn write_chunk(&self, relative_path: String, bytes: Vec) -> Result<(), VnidropError> { + ReceiveOutputSink::write_chunk(self, relative_path, bytes) + } + + fn finish_file(&self, relative_path: String) -> Result { + ReceiveOutputSink::finish_file(self, relative_path.clone())?; + Ok(PublishedOutput { + locator_kind: ReceivedLocatorKind::AndroidDocument, + locator: format!("content://test/{relative_path}"), + }) + } + + fn abort_file(&self, relative_path: String, reason: String) -> Result<(), VnidropError> { + ReceiveOutputSink::abort_file(self, relative_path, reason) + } +} + pub fn share_path( sender: &VnidropCore, source: &Path, @@ -297,6 +320,23 @@ pub fn receive_with_sink_response( handle.join().unwrap() } +pub fn receive_with_sink_v2_response( + sender: &VnidropCore, + transfer_id: u64, + receiver: Arc, + ticket: String, + output_sink: Arc, + accepted: bool, +) -> Result<(), String> { + let handle = std::thread::spawn(move || { + receiver + .receive_with_output_sink_v2(ticket, output_sink, Some("receiver".to_string())) + .map_err(|error| error.to_string()) + }); + respond_to_pending_request(sender, transfer_id, accepted); + handle.join().unwrap() +} + fn respond_to_pending_request(sender: &VnidropCore, transfer_id: u64, accepted: bool) { let request = wait_for_receiver_request(sender, transfer_id); sender diff --git a/crates/vnidrop/tests/transfer.rs b/crates/vnidrop/tests/transfer.rs index d8af022..c4fd497 100644 --- a/crates/vnidrop/tests/transfer.rs +++ b/crates/vnidrop/tests/transfer.rs @@ -38,6 +38,20 @@ fn transfers_file_between_two_cores() { received.peer_id.as_deref(), Some(sender.core.status().endpoint_id.as_str()) ); + let artifacts = receiver.core.list_received_artifacts().unwrap(); + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0].relative_path, "hello.txt"); + assert_eq!( + artifacts[0].logical_size, + b"hello from vnidrop".len() as u64 + ); + assert_eq!( + artifacts[0].locator, + output_dir.path().join("hello.txt").to_string_lossy() + ); + + receiver.core.delete_receive_history().unwrap(); + assert_eq!(receiver.core.list_received_artifacts().unwrap(), artifacts); } #[test] diff --git a/localization/strings.json b/localization/strings.json index 5942a9b..12bef6b 100644 --- a/localization/strings.json +++ b/localization/strings.json @@ -2926,15 +2926,29 @@ "storage_delete_transfers_description": { "context": "Settings > Storage: confirmation body for deleting all transfer records.", "translations": { - "en": "This clears all sent and received transfer records from your history. Your received files are not deleted. Note: the transfer engine keeps its stored content, so Transfer data may not decrease. This can’t be undone.", - "fr": "Cela efface tous les enregistrements de transferts envoyés et reçus de votre historique. Vos fichiers reçus ne sont pas supprimés. Remarque : le moteur de transfert conserve son contenu stocké, donc les données de transfert peuvent ne pas diminuer. Cette action est irréversible.", - "es": "Esto borra todos los registros de transferencias enviadas y recibidas de su historial. Sus archivos recibidos no se eliminan. Nota: el motor de transferencia conserva su contenido almacenado, por lo que los datos de transferencia pueden no disminuir. Esto no se puede deshacer.", - "it": "Questo cancella dalla cronologia tutti i record dei trasferimenti inviati e ricevuti. I suoi file ricevuti non vengono eliminati. Nota: il motore di trasferimento conserva il contenuto memorizzato, quindi i dati di trasferimento potrebbero non diminuire. Questa operazione non può essere annullata.", - "de": "Dies löscht alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf. Ihre empfangenen Dateien werden nicht gelöscht. Hinweis: Die Übertragungs-Engine behält ihre gespeicherten Inhalte, sodass die Übertragungsdaten möglicherweise nicht abnehmen. Dies kann nicht rückgängig gemacht werden.", - "pt": "Isto elimina do seu histórico todos os registos de transferências enviadas e recebidas. Os seus ficheiros recebidos não são eliminados. Nota: o motor de transferência mantém o conteúdo armazenado, pelo que os dados de transferência podem não diminuir. Isto não pode ser anulado.", - "pl": "To usuwa z historii wszystkie rekordy wysłanych i odebranych transferów. Twoje odebrane pliki nie są usuwane. Uwaga: silnik transferu zachowuje przechowywaną zawartość, więc dane transferu mogą się nie zmniejszyć. Tej operacji nie można cofnąć.", - "nl": "Hiermee worden alle records van verzonden en ontvangen overdrachten uit uw geschiedenis gewist. Uw ontvangen bestanden worden niet verwijderd. Let op: de overdrachtsengine bewaart de opgeslagen inhoud, dus de overdrachtsgegevens nemen mogelijk niet af. Dit kan niet ongedaan worden gemaakt.", - "ru": "Это удалит из истории все записи об отправленных и полученных передачах. Ваши полученные файлы не удаляются. Примечание: механизм передачи сохраняет своё содержимое, поэтому объём данных передачи может не уменьшиться. Это действие нельзя отменить." + "en": "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.", + "fr": "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.", + "es": "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.", + "it": "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.", + "de": "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.", + "pt": "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.", + "pl": "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ąć.", + "nl": "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.", + "ru": "Это удалит из истории все записи об отправленных и полученных передачах. Полученные файлы не удаляются. Кэшированное общее содержимое, которое больше не требуется, освобождается автоматически; это может занять некоторое время. Это действие нельзя отменить." + } + }, + "storage_app_data": { + "context": "Settings > Storage: label for non-transfer application data.", + "translations": { + "en": "App data", + "fr": "Données de l’app", + "es": "Datos de la aplicación", + "it": "Dati dell’app", + "de": "App-Daten", + "pt": "Dados da aplicação", + "pl": "Dane aplikacji", + "nl": "Appgegevens", + "ru": "Данные приложения" } }, "storage_deleting": { @@ -2954,15 +2968,15 @@ "storage_footer": { "context": "Settings > Storage: footer explaining how storage is managed.", "translations": { - "en": "Transfer data is managed by the transfer engine — your history plus the content of files you’ve shared. The button below clears your transfer records; the engine keeps its stored content, so this value may not drop. Received files are your downloaded files and are never deleted here.", - "fr": "Les données de transfert sont gérées par le moteur de transfert — votre historique ainsi que le contenu des fichiers que vous avez partagés. Le bouton ci-dessous efface vos enregistrements de transferts ; le moteur conserve son contenu stocké, donc cette valeur peut ne pas baisser. Les fichiers reçus sont vos fichiers téléchargés et ne sont jamais supprimés ici.", - "es": "Los datos de transferencia los gestiona el motor de transferencia: su historial más el contenido de los archivos que ha compartido. El botón de abajo borra sus registros de transferencias; el motor conserva su contenido almacenado, por lo que este valor puede no bajar. Los archivos recibidos son los archivos que ha descargado y nunca se eliminan aquí.", - "it": "I dati di trasferimento sono gestiti dal motore di trasferimento: la sua cronologia più il contenuto dei file che ha condiviso. Il pulsante qui sotto cancella i record dei trasferimenti; il motore conserva il contenuto memorizzato, quindi questo valore potrebbe non diminuire. I file ricevuti sono i file che ha scaricato e non vengono mai eliminati qui.", - "de": "Übertragungsdaten werden von der Übertragungs-Engine verwaltet – Ihr Verlauf sowie der Inhalt der von Ihnen geteilten Dateien. Die Schaltfläche unten löscht Ihre Übertragungsdatensätze; die Engine behält ihre gespeicherten Inhalte, sodass dieser Wert möglicherweise nicht sinkt. Empfangene Dateien sind Ihre heruntergeladenen Dateien und werden hier niemals gelöscht.", - "pt": "Os dados de transferência são geridos pelo motor de transferência — o seu histórico mais o conteúdo dos ficheiros que partilhou. O botão abaixo elimina os seus registos de transferências; o motor mantém o conteúdo armazenado, pelo que este valor pode não descer. Os ficheiros recebidos são os ficheiros que descarregou e nunca são eliminados aqui.", - "pl": "Danymi transferu zarządza silnik transferu — Twoja historia oraz zawartość udostępnionych plików. Przycisk poniżej usuwa rekordy transferów; silnik zachowuje przechowywaną zawartość, więc ta wartość może się nie zmniejszyć. Odebrane pliki to Twoje pobrane pliki i nigdy nie są tu usuwane.", - "nl": "Overdrachtsgegevens worden beheerd door de overdrachtsengine — uw geschiedenis plus de inhoud van de bestanden die u hebt gedeeld. De knop hieronder wist uw overdrachtsrecords; de engine bewaart de opgeslagen inhoud, dus deze waarde daalt mogelijk niet. Ontvangen bestanden zijn uw gedownloade bestanden en worden hier nooit verwijderd.", - "ru": "Данными передачи управляет механизм передачи — ваша история плюс содержимое файлов, которыми вы поделились. Кнопка ниже удаляет записи о передачах; механизм сохраняет своё содержимое, поэтому это значение может не уменьшиться. Полученные файлы — это загруженные вами файлы, и они никогда не удаляются здесь." + "en": "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.", + "fr": "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.", + "es": "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í.", + "it": "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.", + "de": "Ü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.", + "pt": "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.", + "pl": "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.", + "nl": "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.", + "ru": "Данные передачи включают историю и кэшированное содержимое активных раздач. Ненужный кэш освобождается автоматически после удаления записей; это может занять некоторое время. Полученные файлы учитываются в этой сводке, но никогда не удаляются здесь." } }, "storage_received_files": { 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 079d36a..ab61c68 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 @@ -12,7 +12,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.core.net.toUri -import uniffi.vnidrop.ReceiveOutputSink +import uniffi.vnidrop.PublishedOutput +import uniffi.vnidrop.ReceiveOutputSinkV2 +import uniffi.vnidrop.ReceivedLocatorKind import java.io.File import java.io.OutputStream import java.net.URLConnection @@ -54,7 +56,52 @@ private class AndroidFileSystemService( ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value) } - override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = + override suspend fun inspectReceivedArtifacts(artifacts: List): ReceivedStorageInspection { + var bytes = 0UL + var existing = 0 + var missing = 0 + var inaccessible = 0 + for (artifact in artifacts) { + when (artifact.locatorKind) { + ReceivedLocatorKind.FILESYSTEM_PATH -> { + val file = File(artifact.locator) + if (file.isFile) { + bytes += file.length().toULong() + existing += 1 + } else { + missing += 1 + } + } + ReceivedLocatorKind.ANDROID_MEDIA_STORE, ReceivedLocatorKind.ANDROID_DOCUMENT -> { + val result = runCatching { + context.contentResolver.query( + artifact.locator.toUri(), + arrayOf(android.provider.OpenableColumns.SIZE), + null, + null, + null, + )?.use { cursor -> + if (!cursor.moveToFirst()) null else cursor.getLong(0).coerceAtLeast(0L).toULong() + } + } + result.fold( + onSuccess = { size -> + if (size == null) missing += 1 else { + bytes += size + existing += 1 + } + }, + onFailure = { inaccessible += 1 }, + ) + } + } + } + return ReceivedStorageInspection(bytes, existing, missing, inaccessible) + } + + override suspend fun temporaryUsage(): ULong = directorySize(context.cacheDir) + + override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = when (folder.kind) { ReceiveFolderKind.AndroidPublicDownloads -> AndroidMediaStoreDownloadsSink(context) ReceiveFolderKind.AndroidTreeUri -> AndroidTreeReceiveOutputSink(context, folder.value.toUri()) @@ -209,7 +256,7 @@ private fun Context.expandShareDirectory(folder: PickedShareFile): List { } return parts } + +private fun directorySize(directory: File): ULong = + if (!directory.exists()) 0UL else directory.walkTopDown() + .filter(File::isFile) + .fold(0UL) { total, file -> total + file.length().toULong() } diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index 7e4c535..e4bda0a 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -200,9 +200,10 @@ Beendet Wird berechnet… Alle Übertragungen löschen - Dies löscht alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf. Ihre empfangenen Dateien werden nicht gelöscht. Hinweis: Die Übertragungs-Engine behält ihre gespeicherten Inhalte, sodass die Übertragungsdaten möglicherweise nicht abnehmen. Dies kann nicht rückgängig gemacht werden. + 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. + App-Daten Wird gelöscht… - Übertragungsdaten werden von der Übertragungs-Engine verwaltet – Ihr Verlauf sowie der Inhalt der von Ihnen geteilten Dateien. Die Schaltfläche unten löscht Ihre Übertragungsdatensätze; die Engine behält ihre gespeicherten Inhalte, sodass dieser Wert möglicherweise nicht sinkt. Empfangene Dateien sind Ihre heruntergeladenen Dateien und werden hier niemals 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. Empfangene Dateien Temporäre Dateien Speicher diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index 6000692..7a5f126 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -200,9 +200,10 @@ Detenido Calculando… Eliminar todas las transferencias - Esto borra todos los registros de transferencias enviadas y recibidas de su historial. Sus archivos recibidos no se eliminan. Nota: el motor de transferencia conserva su contenido almacenado, por lo que los datos de transferencia pueden no disminuir. Esto no se puede deshacer. + 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. + Datos de la aplicación Eliminando… - Los datos de transferencia los gestiona el motor de transferencia: su historial más el contenido de los archivos que ha compartido. El botón de abajo borra sus registros de transferencias; el motor conserva su contenido almacenado, por lo que este valor puede no bajar. Los archivos recibidos son los archivos que ha descargado y nunca se eliminan aquí. + 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í. Archivos recibidos Archivos temporales Almacenamiento diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index cf78c95..7adc010 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -200,9 +200,10 @@ Arrêté Calcul… Supprimer tous les transferts - Cela efface tous les enregistrements de transferts envoyés et reçus de votre historique. Vos fichiers reçus ne sont pas supprimés. Remarque : le moteur de transfert conserve son contenu stocké, donc les données de transfert peuvent ne pas diminuer. Cette action est irréversible. + 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. + Données de l’app Suppression… - Les données de transfert sont gérées par le moteur de transfert — votre historique ainsi que le contenu des fichiers que vous avez partagés. Le bouton ci-dessous efface vos enregistrements de transferts ; le moteur conserve son contenu stocké, donc cette valeur peut ne pas baisser. Les fichiers reçus sont vos fichiers téléchargés et ne sont jamais supprimés ici. + 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. Fichiers reçus Fichiers temporaires Stockage diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index 2b44a36..8ef52ac 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -200,9 +200,10 @@ Interrotto Calcolo… Elimina tutti i trasferimenti - Questo cancella dalla cronologia tutti i record dei trasferimenti inviati e ricevuti. I suoi file ricevuti non vengono eliminati. Nota: il motore di trasferimento conserva il contenuto memorizzato, quindi i dati di trasferimento potrebbero non diminuire. Questa operazione non può essere annullata. + 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. + Dati dell’app Eliminazione… - I dati di trasferimento sono gestiti dal motore di trasferimento: la sua cronologia più il contenuto dei file che ha condiviso. Il pulsante qui sotto cancella i record dei trasferimenti; il motore conserva il contenuto memorizzato, quindi questo valore potrebbe non diminuire. I file ricevuti sono i file che ha scaricato e non vengono mai eliminati qui. + 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. File ricevuti File temporanei Archiviazione diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index 3ade85c..c8c5e12 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -200,9 +200,10 @@ Gestopt Berekenen… Alle overdrachten verwijderen - Hiermee worden alle records van verzonden en ontvangen overdrachten uit uw geschiedenis gewist. Uw ontvangen bestanden worden niet verwijderd. Let op: de overdrachtsengine bewaart de opgeslagen inhoud, dus de overdrachtsgegevens nemen mogelijk niet af. Dit kan niet ongedaan worden gemaakt. + 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. + Appgegevens Verwijderen… - Overdrachtsgegevens worden beheerd door de overdrachtsengine — uw geschiedenis plus de inhoud van de bestanden die u hebt gedeeld. De knop hieronder wist uw overdrachtsrecords; de engine bewaart de opgeslagen inhoud, dus deze waarde daalt mogelijk niet. Ontvangen bestanden zijn uw gedownloade bestanden en worden hier nooit verwijderd. + 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. Ontvangen bestanden Tijdelijke bestanden Opslag diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index 15a5f00..7e3c6b5 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -200,9 +200,10 @@ Zatrzymany Obliczanie… Usuń wszystkie transfery - To usuwa z historii wszystkie rekordy wysłanych i odebranych transferów. Twoje odebrane pliki nie są usuwane. Uwaga: silnik transferu zachowuje przechowywaną zawartość, więc dane transferu mogą się nie zmniejszyć. Tej operacji nie można cofnąć. + 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ąć. + Dane aplikacji Usuwanie… - Danymi transferu zarządza silnik transferu — Twoja historia oraz zawartość udostępnionych plików. Przycisk poniżej usuwa rekordy transferów; silnik zachowuje przechowywaną zawartość, więc ta wartość może się nie zmniejszyć. Odebrane pliki to Twoje pobrane pliki i nigdy nie są tu usuwane. + 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. Odebrane pliki Pliki tymczasowe Pamięć diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index 4071ac2..1e2b507 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -200,9 +200,10 @@ Parada A calcular… Eliminar todas as transferências - Isto elimina do seu histórico todos os registos de transferências enviadas e recebidas. Os seus ficheiros recebidos não são eliminados. Nota: o motor de transferência mantém o conteúdo armazenado, pelo que os dados de transferência podem não diminuir. Isto não pode ser anulado. + 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. + Dados da aplicação A eliminar… - Os dados de transferência são geridos pelo motor de transferência — o seu histórico mais o conteúdo dos ficheiros que partilhou. O botão abaixo elimina os seus registos de transferências; o motor mantém o conteúdo armazenado, pelo que este valor pode não descer. Os ficheiros recebidos são os ficheiros que descarregou e nunca são eliminados aqui. + 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. Ficheiros recebidos Ficheiros temporários Armazenamento diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index 869bc11..44894c0 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -200,9 +200,10 @@ Остановлено Вычисление… Удалить все передачи - Это удалит из истории все записи об отправленных и полученных передачах. Ваши полученные файлы не удаляются. Примечание: механизм передачи сохраняет своё содержимое, поэтому объём данных передачи может не уменьшиться. Это действие нельзя отменить. + Это удалит из истории все записи об отправленных и полученных передачах. Полученные файлы не удаляются. Кэшированное общее содержимое, которое больше не требуется, освобождается автоматически; это может занять некоторое время. Это действие нельзя отменить. + Данные приложения Удаление… - Данными передачи управляет механизм передачи — ваша история плюс содержимое файлов, которыми вы поделились. Кнопка ниже удаляет записи о передачах; механизм сохраняет своё содержимое, поэтому это значение может не уменьшиться. Полученные файлы — это загруженные вами файлы, и они никогда не удаляются здесь. + Данные передачи включают историю и кэшированное содержимое активных раздач. Ненужный кэш освобождается автоматически после удаления записей; это может занять некоторое время. Полученные файлы учитываются в этой сводке, но никогда не удаляются здесь. Полученные файлы Временные файлы Хранилище diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index e4e00e1..7ccbd3b 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -200,9 +200,10 @@ Stopped Calculating… Delete all transfers - This clears all sent and received transfer records from your history. Your received files are not deleted. Note: the transfer engine keeps its stored content, so Transfer data may not decrease. This can’t be undone. + 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. + App data Deleting… - Transfer data is managed by the transfer engine — your history plus the content of files you’ve shared. The button below clears your transfer records; the engine keeps its stored content, so this value may not drop. Received files are your downloaded files and are never deleted here. + 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. Received files Temporary files Storage diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index c4ecf95..0fde754 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -85,6 +85,7 @@ fun App( dependencies.environment, dependencies.deviceInfoProvider, dependencies.fileSystemService, + graph.coreRepository, graph.preferencesRepository, dependencies.localNotificationService, graph.messages, 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 c3e7e2f..72d0876 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt @@ -3,6 +3,7 @@ package com.vnidrop.app.core import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import uniffi.vnidrop.ReceiveOutputSink +import uniffi.vnidrop.ReceiveOutputSinkV2 data class CoreStatus( val endpointId: String, @@ -113,6 +114,23 @@ data class CoreState( val lastInspection: TicketInspectionModel? = null, ) +data class CoreStorageUsageModel( + val blobStoreBytes: ULong, + val databaseBytes: ULong, + val logsBytes: ULong, + val previewsBytes: ULong, + val otherCoreBytes: ULong, +) { + val appDataBytes: ULong get() = databaseBytes + logsBytes + previewsBytes + otherCoreBytes +} + +data class ReceivedArtifactModel( + val id: String, + val locator: String, + val locatorKind: uniffi.vnidrop.ReceivedLocatorKind, + val logicalSize: ULong, +) + sealed interface CoreSignal { data class ApprovalChanged(val transferId: ULong) : CoreSignal data class ReceiverHistoryChanged(val transferId: ULong) : CoreSignal @@ -144,6 +162,9 @@ interface CoreGateway { suspend fun inspectTicket(ticket: String): Result suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result 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 receivedArtifacts(): Result> suspend fun cancel(transferId: ULong): Result suspend fun delete(transferId: ULong): Result suspend fun clearReceiveHistory(): 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 8744ef3..dc8b05a 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -17,6 +17,7 @@ import kotlin.random.Random import uniffi.vnidrop.CoreEvent import uniffi.vnidrop.CoreEventSink import uniffi.vnidrop.ReceiveOutputSink +import uniffi.vnidrop.ReceiveOutputSinkV2 import uniffi.vnidrop.ReceiverRequest import uniffi.vnidrop.ShareMetadataInput import uniffi.vnidrop.ShareResult @@ -133,6 +134,37 @@ class CoreRepository( refreshSnapshot() } + override suspend fun receiveWithOutputSinkV2( + ticket: String, + outputSink: ReceiveOutputSinkV2, + receiverName: String, + ): Result = runCore { + requireCore().receiveWithOutputSinkV2(ticket, outputSink, receiverName.ifBlank { null }) + refreshSnapshot() + } + + override suspend fun storageUsage(): Result = runCore { + val usage = requireCore().storageUsage() + CoreStorageUsageModel( + blobStoreBytes = usage.blobStoreBytes, + databaseBytes = usage.databaseBytes, + logsBytes = usage.logsBytes, + previewsBytes = usage.previewsBytes, + otherCoreBytes = usage.otherCoreBytes, + ) + } + + override suspend fun receivedArtifacts(): Result> = runCore { + requireCore().listReceivedArtifacts().map { artifact -> + ReceivedArtifactModel( + id = artifact.id, + locator = artifact.locator, + locatorKind = artifact.locatorKind, + logicalSize = artifact.logicalSize, + ) + } + } + override suspend fun cancel(transferId: ULong): Result = runCore { requireCore().cancelTransfer(transferId) refreshSnapshot() 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 5c0cd4b..537f0b6 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt @@ -1,7 +1,7 @@ package com.vnidrop.app.core import androidx.compose.runtime.Composable -import uniffi.vnidrop.ReceiveOutputSink +import uniffi.vnidrop.ReceiveOutputSinkV2 enum class ReceiveFolderKind { FileSystemPath, @@ -19,6 +19,13 @@ data class ReceiveFolder( val displayName: String, ) +data class ReceivedStorageInspection( + val existingBytes: ULong, + val existingCount: Int, + val missingCount: Int, + val inaccessibleCount: Int, +) + enum class FolderAccessStatus { Writable, PermissionRequired, @@ -32,7 +39,9 @@ interface FileSystemService { fun effectiveReceiveFolder(configuredFolder: ReceiveFolder): ReceiveFolder = if (supportsCustomReceiveFolders) configuredFolder else defaultReceiveFolder() suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus - fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? + suspend fun inspectReceivedArtifacts(artifacts: List): ReceivedStorageInspection + suspend fun temporaryUsage(): ULong + fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? fun canRevealReceiveFolder(folder: ReceiveFolder): Boolean = false suspend fun revealReceiveFolder(folder: ReceiveFolder): Result = Result.failure(UnsupportedOperationException("Revealing the receive folder is not supported")) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt index c841f2e..495f5f6 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt @@ -162,7 +162,7 @@ class ReceiveViewModel( } val outputSink = fileSystemService.createReceiveOutputSink(folder) val result = if (outputSink != null) { - repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName) + repository.receiveWithOutputSinkV2(current.ticket, outputSink, current.receiverName) } else { repository.receive(current.ticket, folder.value, current.receiverName) } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt index 258a4d9..6f3d2ec 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt @@ -20,6 +20,7 @@ import vnidrop.shared.generated.resources.notifications_title import vnidrop.shared.generated.resources.preferences_title import vnidrop.shared.generated.resources.settings_subtitle import vnidrop.shared.generated.resources.settings_title +import vnidrop.shared.generated.resources.storage_title @Composable internal fun SettingsOverview( @@ -41,6 +42,13 @@ internal fun SettingsOverview( ) } SettingsGroup { + SettingsRow( + icon = SettingsIcons.Drive, + title = stringResource(Res.string.storage_title), + selected = state.selectedSection == SettingsSection.Storage, + onClick = { onSectionSelected(SettingsSection.Storage) }, + ) + SettingsDivider() SettingsRow( icon = SettingsIcons.Device, title = stringResource(Res.string.preferences_title), 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 20dff4b..93c3bd7 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 @@ -35,5 +35,6 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) { onBugContactChanged = viewModel::setBugContact, onBugIncludeLogsChanged = viewModel::setBugIncludeLogs, onSubmitBugReport = viewModel::submitBugReport, + onDeleteAllTransfers = viewModel::deleteAllTransfers, ) } 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 786042d..35212a8 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 @@ -29,6 +29,7 @@ fun SettingsScreen( onBugContactChanged: (String) -> Unit, onBugIncludeLogsChanged: (Boolean) -> Unit, onSubmitBugReport: () -> Unit, + onDeleteAllTransfers: () -> Unit = {}, ) { if (windowClass == WindowClass.Desktop) { Row( @@ -58,6 +59,7 @@ fun SettingsScreen( onBugContactChanged = onBugContactChanged, onBugIncludeLogsChanged = onBugIncludeLogsChanged, onSubmitBugReport = onSubmitBugReport, + onDeleteAllTransfers = onDeleteAllTransfers, ) } } @@ -91,6 +93,7 @@ fun SettingsScreen( onBugContactChanged = onBugContactChanged, onBugIncludeLogsChanged = onBugIncludeLogsChanged, onSubmitBugReport = onSubmitBugReport, + onDeleteAllTransfers = onDeleteAllTransfers, ) } } @@ -116,12 +119,14 @@ private fun SettingsSectionContent( onBugContactChanged: (String) -> Unit, onBugIncludeLogsChanged: (Boolean) -> Unit, onSubmitBugReport: () -> Unit, + onDeleteAllTransfers: () -> Unit, ) { when (section) { SettingsSection.Overview -> Unit SettingsSection.Preferences -> PreferencesSettings(state, onUsernameChanged, onChooseFolder, onResetFolder, onBack, showBack) SettingsSection.Appearance -> AppearanceSettings(state.themeMode, onThemeModeChanged, onBack, showBack) SettingsSection.Notifications -> NotificationSettings(state, onNotificationsChanged, onOpenNotificationSettings, onBack, showBack) + SettingsSection.Storage -> StorageSettings(state, onDeleteAllTransfers, 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 1cfc999..e3deea1 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 @@ -6,6 +6,7 @@ import com.vnidrop.app.DeviceInfo import com.vnidrop.app.DeviceInfoProvider import com.vnidrop.app.PlatformEnvironment import com.vnidrop.app.core.FileSystemService +import com.vnidrop.app.core.CoreGateway import com.vnidrop.app.core.FolderAccessStatus import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.diagnostics.BugReportDraft @@ -48,10 +49,23 @@ enum class SettingsSection { Preferences, Appearance, Notifications, + Storage, About, BugReport, } +data class StorageBreakdown( + val transferCacheBytes: ULong, + val appDataBytes: ULong, + val temporaryBytes: ULong, + val receivedBytes: ULong, + val receivedFileCount: Int, + val missingReceivedFileCount: Int, + val inaccessibleReceivedFileCount: Int, +) { + val deviceImpactBytes: ULong get() = transferCacheBytes + appDataBytes + temporaryBytes + receivedBytes +} + data class SettingsState( val selectedSection: SettingsSection = SettingsSection.Overview, val username: String = "", @@ -73,6 +87,9 @@ data class SettingsState( val bugIncludeLogs: Boolean = true, val isSubmittingBugReport: Boolean = false, val bugLogPreviewBytes: Int = 0, + val storage: StorageBreakdown? = null, + val isCalculatingStorage: Boolean = false, + val isDeletingTransfers: Boolean = false, ) sealed interface SettingsEffect { @@ -83,6 +100,7 @@ class SettingsViewModel( private val environment: PlatformEnvironment, private val deviceInfoProvider: DeviceInfoProvider, private val fileSystemService: FileSystemService, + private val repository: CoreGateway, private val preferencesRepository: PreferencesRepository, private val notifications: LocalNotificationService, private val messages: UiMessageController, @@ -130,6 +148,7 @@ class SettingsViewModel( fun selectSection(section: SettingsSection) { _state.update { it.copy(selectedSection = section) } when (section) { + SettingsSection.Storage -> loadStorageUsage() SettingsSection.About, SettingsSection.BugReport -> { loadDeviceInfo() if (section == SettingsSection.BugReport) refreshBugLogPreview() @@ -138,6 +157,53 @@ class SettingsViewModel( } } + fun loadStorageUsage() { + if (_state.value.isCalculatingStorage) return + viewModelScope.launch { + _state.update { it.copy(isCalculatingStorage = true) } + try { + val coreUsage = repository.storageUsage().getOrThrow() + val artifacts = repository.receivedArtifacts().getOrThrow() + val received = fileSystemService.inspectReceivedArtifacts(artifacts) + _state.update { + it.copy( + storage = StorageBreakdown( + transferCacheBytes = coreUsage.blobStoreBytes, + appDataBytes = coreUsage.appDataBytes, + temporaryBytes = fileSystemService.temporaryUsage(), + receivedBytes = received.existingBytes, + receivedFileCount = received.existingCount, + missingReceivedFileCount = received.missingCount, + inaccessibleReceivedFileCount = received.inaccessibleCount, + ), + isCalculatingStorage = false, + ) + } + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + _state.update { it.copy(isCalculatingStorage = false) } + messages.error(error) + } + } + } + + fun deleteAllTransfers() { + if (_state.value.isDeletingTransfers) return + viewModelScope.launch { + _state.update { it.copy(isDeletingTransfers = true) } + val failures = repository.state.value.transfers + .map { repository.delete(it.transferId) } + .count { it.isFailure } + _state.update { it.copy(isDeletingTransfers = false) } + if (failures == 0) { + loadStorageUsage() + } else { + messages.error(IllegalStateException("Could not delete $failures transfer records")) + } + } + } + fun setUsername(value: String) { hasLocalUsernameDraft = true _state.update { it.copy(username = value) } 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 new file mode 100644 index 0000000..07b1b48 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt @@ -0,0 +1,80 @@ +package com.vnidrop.app.feature.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import com.vnidrop.app.ui.state.formatBytes +import com.vnidrop.app.ui.theme.LocalVniDropColors +import org.jetbrains.compose.resources.stringResource +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.storage_app_data +import vnidrop.shared.generated.resources.storage_calculating +import vnidrop.shared.generated.resources.storage_delete_transfers +import vnidrop.shared.generated.resources.storage_deleting +import vnidrop.shared.generated.resources.storage_total +import vnidrop.shared.generated.resources.storage_received_files +import vnidrop.shared.generated.resources.storage_temporary +import vnidrop.shared.generated.resources.storage_title +import vnidrop.shared.generated.resources.storage_transfer_data +import vnidrop.shared.generated.resources.storage_footer + +@Composable +internal fun StorageSettings( + state: SettingsState, + onDeleteAllTransfers: () -> Unit, + onBack: () -> Unit, + showBack: Boolean, +) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + SettingsTopBar(stringResource(Res.string.storage_title), onBack, showBack) + val storage = state.storage + if (storage == null || state.isCalculatingStorage) { + SettingsGroup { + SettingsRow( + icon = SettingsIcons.Drive, + title = stringResource(Res.string.storage_calculating), + trailing = { CircularProgressIndicator() }, + ) + } + } else { + SettingsGroup { + StorageRow(stringResource(Res.string.storage_transfer_data), storage.transferCacheBytes) + SettingsDivider() + StorageRow(stringResource(Res.string.storage_app_data), storage.appDataBytes) + SettingsDivider() + StorageRow(stringResource(Res.string.storage_temporary), storage.temporaryBytes) + SettingsDivider() + SettingsRow( + icon = SettingsIcons.Folder, + title = stringResource(Res.string.storage_received_files), + value = formatBytes(storage.receivedBytes), + ) + SettingsDivider() + StorageRow(stringResource(Res.string.storage_total), storage.deviceImpactBytes) + } + } + Button( + onClick = onDeleteAllTransfers, + enabled = !state.isDeletingTransfers, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + ) { + Text(stringResource(if (state.isDeletingTransfers) Res.string.storage_deleting else Res.string.storage_delete_transfers)) + } + Text( + stringResource(Res.string.storage_footer), + style = MaterialTheme.typography.bodySmall, + color = LocalVniDropColors.current.foregroundLighter, + ) + } +} + +@Composable +private fun StorageRow(title: String, bytes: ULong) { + SettingsRow(icon = SettingsIcons.Drive, title = title, value = formatBytes(bytes)) +} 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 b0a95e7..b50186b 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -666,6 +666,7 @@ class ViewModelsTest { environment(), { DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") }, fileSystem, + FakeCoreGateway(), preferences, notifications, UiMessageController(), 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 bc29f64..8b310e8 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -1,12 +1,15 @@ package com.vnidrop.app.support import com.vnidrop.app.core.CoreGateway +import com.vnidrop.app.core.CoreStorageUsageModel import com.vnidrop.app.core.CoreSignal import com.vnidrop.app.core.CoreState import com.vnidrop.app.core.FileSystemService import com.vnidrop.app.core.FolderAccessStatus import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.ReceiveFolder +import com.vnidrop.app.core.ReceivedArtifactModel +import com.vnidrop.app.core.ReceivedStorageInspection import com.vnidrop.app.core.ReceiverRequestModel import com.vnidrop.app.core.Share import com.vnidrop.app.core.ShareAccessPolicy @@ -27,6 +30,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import uniffi.vnidrop.ReceiveOutputSink +import uniffi.vnidrop.ReceiveOutputSinkV2 class FakeCoreGateway : CoreGateway { val mutableState = MutableStateFlow(CoreState()) @@ -135,6 +139,17 @@ class FakeCoreGateway : CoreGateway { awaitReceiveIfNeeded() return receiveResult } + override suspend fun receiveWithOutputSinkV2(ticket: String, outputSink: ReceiveOutputSinkV2, receiverName: String): Result { + receiveCount += 1 + lastReceiveTicket = ticket + lastReceiveReceiverName = receiverName + awaitReceiveIfNeeded() + return receiveResult + } + override suspend fun storageUsage(): Result = Result.success( + CoreStorageUsageModel(0UL, 0UL, 0UL, 0UL, 0UL), + ) + override suspend fun receivedArtifacts(): Result> = Result.success(emptyList()) override suspend fun cancel(transferId: ULong): Result { cancelledTransfers += transferId return Result.success(Unit) @@ -227,7 +242,10 @@ class FakeFileSystemService( override fun effectiveReceiveFolder(configuredFolder: ReceiveFolder) = effectiveFolder ?: super.effectiveReceiveFolder(configuredFolder) override suspend fun validateReceiveFolder(folder: ReceiveFolder) = FolderAccessStatus.Writable - override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null + 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 fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = null override fun canRevealReceiveFolder(folder: ReceiveFolder) = canRevealFolder override suspend fun revealReceiveFolder(folder: ReceiveFolder): Result { revealedFolders += folder 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 ef26c9f..8e1541b 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 @@ -2,7 +2,7 @@ package com.vnidrop.app.core import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import uniffi.vnidrop.ReceiveOutputSink +import uniffi.vnidrop.ReceiveOutputSinkV2 import java.io.File @Composable @@ -26,7 +26,25 @@ private class JvmFileSystemService : FileSystemService { }.getOrDefault(FolderAccessStatus.Unavailable) } - override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null + override suspend fun inspectReceivedArtifacts(artifacts: List): ReceivedStorageInspection { + var bytes = 0UL + var existing = 0 + var missing = 0 + for (artifact in artifacts) { + val file = File(artifact.locator) + if (file.isFile) { + bytes += file.length().toULong() + existing += 1 + } else { + missing += 1 + } + } + return ReceivedStorageInspection(bytes, existing, missing, 0) + } + + override suspend fun temporaryUsage(): ULong = 0UL + + override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = null override suspend fun sharePickedFiles( repository: CoreGateway,