fix(storage): reclaim transfer cache and track received files

This commit is contained in:
2026-07-22 16:03:37 +02:00
parent 627c205853
commit b46c5e7d72
46 changed files with 1229 additions and 124 deletions

View File

@@ -62,6 +62,10 @@ final class FakeCoreGateway: CoreGateway {
func cancel(transferId: UInt64) async -> Result<Void, Error> { cancelledTransfers.append(transferId); return cancelResult } func cancel(transferId: UInt64) async -> Result<Void, Error> { cancelledTransfers.append(transferId); return cancelResult }
func delete(transferId: UInt64) async -> Result<Void, Error> { deletedTransfers.append(transferId); return deleteResult } func delete(transferId: UInt64) async -> Result<Void, Error> { deletedTransfers.append(transferId); return deleteResult }
func clearReceiveHistory() async -> Result<UInt64, Error> { clearReceiveHistoryCount += 1; return clearReceiveHistoryResult } func clearReceiveHistory() async -> Result<UInt64, Error> { clearReceiveHistoryCount += 1; return clearReceiveHistoryResult }
func storageUsage() async -> Result<CoreStorageUsageModel, Error> {
.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 receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> { .success(requests[transferId] ?? []) }
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error> { func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error> {
responses.append((requestId, accepted, reason)) responses.append((requestId, accepted, reason))

View File

@@ -2,6 +2,16 @@ import Foundation
import Combine import Combine
import VnidropCore 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` /// Seam between the feature models and the Rust core, mirroring `CoreGateway`
/// in the KMP `shared` module. `CoreRepository` is the production implementation; /// in the KMP `shared` module. `CoreRepository` is the production implementation;
/// tests substitute a fake so the models can be exercised without the FFI. /// 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<Void, Error> func cancel(transferId: UInt64) async -> Result<Void, Error>
func delete(transferId: UInt64) async -> Result<Void, Error> func delete(transferId: UInt64) async -> Result<Void, Error>
func clearReceiveHistory() async -> Result<UInt64, Error> func clearReceiveHistory() async -> Result<UInt64, Error>
func storageUsage() async -> Result<CoreStorageUsageModel, Error>
func receivedArtifacts() async -> Result<[ReceivedArtifactModel], Error>
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error>
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error> func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error>
func refresh() async -> Result<Void, Error> func refresh() async -> Result<Void, Error>

View File

@@ -143,6 +143,25 @@ final class CoreRepository: ObservableObject, CoreGateway {
} }
} }
func storageUsage() async -> Result<CoreStorageUsageModel, Error> {
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> { func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> {
await runCore { await runCore {
try self.requireCore().listReceiverRequests(transferId: transferId).map { $0.toModel() } try self.requireCore().listReceiverRequests(transferId: transferId).map { $0.toModel() }

View File

@@ -27,9 +27,10 @@ enum SettingsSection: Hashable {
/// On-disk usage breakdown for the Storage screen. /// On-disk usage breakdown for the Storage screen.
struct StorageBreakdown: Equatable { struct StorageBreakdown: Equatable {
var receivedFiles: UInt64 = 0 var receivedFiles: UInt64 = 0
var transferData: UInt64 = 0 var transferCache: UInt64 = 0
var appData: UInt64 = 0
var temporary: UInt64 = 0 var temporary: UInt64 = 0
var total: UInt64 { receivedFiles + transferData + temporary } var total: UInt64 { receivedFiles + transferCache + appData + temporary }
} }
struct SettingsState: Equatable { struct SettingsState: Equatable {
@@ -286,19 +287,29 @@ final class SettingsModel: ObservableObject {
func loadStorageUsage() { func loadStorageUsage() {
if state.isCalculatingStorage { return } if state.isCalculatingStorage { return }
state.isCalculatingStorage = true state.isCalculatingStorage = true
let coreDir = environment.defaultCoreDataDir
let receiveDir = state.receiveFolder?.isFileSystemPath == true ? state.receiveFolder?.value : nil
let tempDir = NSTemporaryDirectory() let tempDir = NSTemporaryDirectory()
Task.detached { Task {
let breakdown = StorageBreakdown( let coreResult = await repository.storageUsage()
receivedFiles: receiveDir.map { SettingsModel.directorySize($0) } ?? 0, let artifactsResult = await repository.receivedArtifacts()
transferData: SettingsModel.directorySize(coreDir), guard case .success(let core) = coreResult,
temporary: SettingsModel.directorySize(tempDir) case .success(let artifacts) = artifactsResult else {
) state.isCalculatingStorage = false
await MainActor.run { [weak self] in return
self?.state.storage = breakdown
self?.state.isCalculatingStorage = false
} }
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 } if state.isDeletingTransfers { return }
state.isDeletingTransfers = true state.isDeletingTransfers = true
Task { Task {
var failures = 0
for id in repository.state.transfers.map(\.transferId) { 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() _ = await repository.refresh()
state.isDeletingTransfers = false state.isDeletingTransfers = false
loadStorageUsage() if failures == 0 {
messages.show(UiMessage(text: .resource("storage_transfers_deleted"), tone: .success)) 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). /// Recursive size of every regular file under `path` (0 if missing).
nonisolated static func directorySize(_ path: String) -> UInt64 { nonisolated static func directorySize(_ path: String) -> UInt64 {
let url = URL(fileURLWithPath: path) let url = URL(fileURLWithPath: path)

View File

@@ -65,7 +65,8 @@ struct StorageSettings: View {
Section { Section {
if let storage = model.state.storage { if let storage = model.state.storage {
LabeledContent(String(localized: "storage_received_files"), value: formatBytes(storage.receivedFiles)) 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_temporary"), value: formatBytes(storage.temporary))
LabeledContent(String(localized: "storage_total")) { LabeledContent(String(localized: "storage_total")) {
Text(formatBytes(storage.total)).fontWeight(.semibold) Text(formatBytes(storage.total)).fontWeight(.semibold)

View File

@@ -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 lapp"
}
},
"it": {
"stringUnit": {
"state": "needs_review",
"value": "Dati dellapp"
}
},
"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": { "storage_calculating": {
"comment": "Settings > Storage: placeholder while a size is being calculated.", "comment": "Settings > Storage: placeholder while a size is being calculated.",
"extractionState": "manual", "extractionState": "manual",
@@ -12136,55 +12196,55 @@
"de": { "de": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "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": { "en": {
"stringUnit": { "stringUnit": {
"state": "translated", "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 cant 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 cant be undone."
} }
}, },
"es": { "es": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "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": { "fr": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "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 nest plus cessaire est récupéré automatiquement, ce qui peut prendre un peu de temps. Cette action est irréversible."
} }
}, },
"it": { "it": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "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": { "nl": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "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": { "pl": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "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": { "pt": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "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": { "ru": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Это удалит из истории все записи об отправленных и полученных передачах. Ваши полученные файлы не удаляются. Примечание: механизм передачи сохраняет своё содержимое, поэтому объём данных передачи может не уменьшиться. Это действие нельзя отменить." "value": "Это удалит из истории все записи об отправленных и полученных передачах. Полученные файлы не удаляются. Кэшированное общее содержимое, которое больше не требуется, освобождается автоматически; это может занять некоторое время. Это действие нельзя отменить."
} }
} }
} }
@@ -12256,55 +12316,55 @@
"de": { "de": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "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": { "en": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "Transfer data is managed by the transfer engine — your history plus the content of files youve 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": { "es": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "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": { "fr": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "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": { "it": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "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": { "nl": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "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": { "pl": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "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": { "pt": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "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": { "ru": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Данными передачи управляет механизм передачи — ваша история плюс содержимое файлов, которыми вы поделились. Кнопка ниже удаляет записи о передачах; механизм сохраняет своё содержимое, поэтому это значение может не уменьшиться. Полученные файлы — это загруженные вами файлы, и они никогда не удаляются здесь." "value": "Данные передачи включают историю и кэшированное содержимое активных раздач. Ненужный кэш освобождается автоматически после удаления записей; это может занять некоторое время. Полученные файлы учитываются в этой сводке, но никогда не удаляются здесь."
} }
} }
} }

View File

@@ -82,10 +82,14 @@ bytes through Kotlin memory.
## Blob Retention Policy ## Blob Retention Policy
Stopping a share immediately removes its provider mapping and approval state, Stopping a share immediately removes its provider mapping and approval state,
so outstanding VniDrop tickets can no longer download content. Physical blob chunks are so outstanding VniDrop tickets can no longer download content. Physical blob
not force-deleted at stop time because content-addressed chunks may be shared by chunks are not force-deleted at stop time because content-addressed chunks may be
another active collection. They remain eligible for the blob store's garbage shared by another active collection. Every active outgoing share has a persistent
collection. Restart reconciliation never restores a stopped share. `vnidrop/share/<local-id>` 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 ## Resource Limits

View File

@@ -127,6 +127,60 @@ pub trait ReceiveOutputSink: Send + Sync {
) -> Result<(), crate::error::VnidropError>; ) -> 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<u8>,
) -> Result<(), crate::error::VnidropError>;
fn finish_file(
&self,
relative_path: String,
) -> Result<PublishedOutput, crate::error::VnidropError>;
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)] #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct RuntimeStatus { pub struct RuntimeStatus {
pub endpoint_id: String, pub endpoint_id: String,

View File

@@ -109,6 +109,10 @@ impl AtomicOutputFile {
self.committed = true; self.committed = true;
Ok(()) Ok(())
} }
pub(crate) fn target(&self) -> &Path {
&self.target
}
} }
/// Publish a fully written temporary file as the final destination without /// Publish a fully written temporary file as the final destination without

View File

@@ -14,7 +14,8 @@ mod transfer_state;
mod util; mod util;
pub use api::{ 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, RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer,
TicketInspection, TransferAccessMode, TransferMetadata, TicketInspection, TransferAccessMode, TransferMetadata,
}; };

View File

@@ -15,12 +15,12 @@ use uuid::Uuid;
use crate::{ use crate::{
access_policy::mode_from_storage, access_policy::mode_from_storage,
api::{CoreEvent, ReceiverRequest, StoredTransfer}, api::{CoreEvent, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, StoredTransfer},
transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus}, transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus},
util::now_ms, util::now_ms,
}; };
const SCHEMA_VERSION: i64 = 4; const SCHEMA_VERSION: i64 = 5;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct Repository { pub(crate) struct Repository {
@@ -47,6 +47,7 @@ pub(crate) struct TransferUpsert<'a> {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct PersistedShare { pub(crate) struct PersistedShare {
pub(crate) transfer_id: u64, pub(crate) transfer_id: u64,
pub(crate) local_id: String,
pub(crate) content_hash: String, pub(crate) content_hash: String,
pub(crate) access_mode: String, pub(crate) access_mode: String,
} }
@@ -58,6 +59,15 @@ pub(crate) struct RecoveredTransfer {
pub(crate) previous_status: TransferStatus, 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) struct ReceiverRequestInsert<'a> {
pub(crate) id: &'a str, pub(crate) id: &'a str,
pub(crate) transfer_id: u64, pub(crate) transfer_id: u64,
@@ -169,6 +179,24 @@ impl Repository {
.execute(&self.pool) .execute(&self.pool)
.await?; .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( sqlx::query(
r#" r#"
CREATE TABLE IF NOT EXISTS transfer_events ( CREATE TABLE IF NOT EXISTS transfer_events (
@@ -330,6 +358,71 @@ impl Repository {
Ok(()) Ok(())
} }
pub(crate) async fn transfer_local_id(&self, transfer_id: u64) -> Result<String> {
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<Vec<ReceivedArtifact>> {
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::<i64, _>("protocol_transfer_id") as u64,
relative_path: row.get("relative_path"),
locator_kind: locator_kind_from_storage(&row.get::<String, _>("locator_kind"))?,
locator: row.get("locator"),
logical_size: row.get::<i64, _>("logical_size") as u64,
published_at: row.get("published_at"),
})
})
.collect()
}
pub(crate) async fn complete_share_import(&self, transfer: TransferUpsert<'_>) -> Result<()> { pub(crate) async fn complete_share_import(&self, transfer: TransferUpsert<'_>) -> Result<()> {
self.maybe_fail_write()?; self.maybe_fail_write()?;
if transfer.direction != TransferDirection::Send if transfer.direction != TransferDirection::Send
@@ -508,7 +601,7 @@ impl Repository {
pub(crate) async fn list_active_shares(&self) -> Result<Vec<PersistedShare>> { pub(crate) async fn list_active_shares(&self) -> Result<Vec<PersistedShare>> {
let rows = sqlx::query( let rows = sqlx::query(
r#" r#"
SELECT transfer_id, content_hash, access_mode SELECT transfer_id, local_id, content_hash, access_mode
FROM transfers FROM transfers
WHERE direction = 'send' WHERE direction = 'send'
AND status = 'sharing' AND status = 'sharing'
@@ -521,8 +614,9 @@ impl Repository {
.into_iter() .into_iter()
.map(|row| PersistedShare { .map(|row| PersistedShare {
transfer_id: row.get::<i64, _>(0) as u64, transfer_id: row.get::<i64, _>(0) as u64,
content_hash: row.get::<String, _>(1), local_id: row.get::<String, _>(1),
access_mode: row.get::<String, _>(2), content_hash: row.get::<String, _>(2),
access_mode: row.get::<String, _>(3),
}) })
.collect()) .collect())
} }
@@ -870,6 +964,23 @@ fn to_db_id(value: u64) -> Result<i64> {
i64::try_from(value).context("transfer id exceeds SQLite signed integer range") 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<ReceivedLocatorKind> {
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<StoredTransfer> { fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> Result<StoredTransfer> {
let direction = row.get::<String, _>("direction"); let direction = row.get::<String, _>("direction");
let status = row.get::<String, _>("status"); let status = row.get::<String, _>("status");

View File

@@ -6,9 +6,9 @@ use serde_json::json;
use super::CoreInner; use super::CoreInner;
use crate::{ use crate::{
api::{ api::{
CoreEvent, CoreEventSink, CoreLimits, ReceiveOutputSink, ReceiverRequest, RuntimeStatus, CoreEvent, CoreEventSink, CoreLimits, CoreStorageUsage, ReceiveOutputSink,
ShareMetadataInput, ShareResult, ShareSource, StoredTransfer, TicketInspection, ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus, ShareMetadataInput,
TransferAccessMode, ShareResult, ShareSource, StoredTransfer, TicketInspection, TransferAccessMode,
}, },
error::VnidropError, error::VnidropError,
filesystem::platform_path, filesystem::platform_path,
@@ -126,6 +126,24 @@ impl VnidropCore {
.map_err(VnidropError::transfer) .map_err(VnidropError::transfer)
} }
pub fn receive_with_output_sink_v2(
&self,
ticket: String,
output_sink: Arc<dyn ReceiveOutputSinkV2>,
receiver_name: Option<String>,
) -> 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> { pub fn cancel_transfer(&self, transfer_id: u64) -> Result<(), VnidropError> {
// Fire the oneshot on this thread before entering the runtime so a // Fire the oneshot on this thread before entering the runtime so a
// blocked export write cannot prevent the cancel signal from being // blocked export write cannot prevent the cancel signal from being
@@ -214,6 +232,16 @@ impl VnidropCore {
.map_err(VnidropError::repository) .map_err(VnidropError::repository)
} }
pub fn list_received_artifacts(&self) -> Result<Vec<ReceivedArtifact>, VnidropError> {
self.block_on(self.inner.repository.list_received_artifacts())
.map_err(VnidropError::repository)
}
pub fn storage_usage(&self) -> Result<CoreStorageUsage, VnidropError> {
self.block_on(self.inner.storage_usage())
.map_err(VnidropError::filesystem)
}
pub fn list_events(&self, transfer_id: Option<u64>) -> Result<Vec<CoreEvent>, VnidropError> { pub fn list_events(&self, transfer_id: Option<u64>) -> Result<Vec<CoreEvent>, VnidropError> {
self.block_on(self.inner.list_events(transfer_id)) self.block_on(self.inner.list_events(transfer_id))
.map_err(VnidropError::repository) .map_err(VnidropError::repository)

View File

@@ -3,7 +3,7 @@ use std::sync::atomic::Ordering;
use anyhow::Result; use anyhow::Result;
use serde_json::json; use serde_json::json;
use super::CoreInner; use super::{share_tag_name, CoreInner};
use crate::{ use crate::{
access_policy::mode_to_storage, access_policy::mode_to_storage,
api::{RuntimeStatus, TransferAccessMode}, api::{RuntimeStatus, TransferAccessMode},
@@ -42,6 +42,7 @@ impl CoreInner {
pub(super) async fn cancel_idle_or_share(&self, transfer_id: u64) -> Result<()> { pub(super) async fn cancel_idle_or_share(&self, transfer_id: u64) -> Result<()> {
let mut active_shares = self.active_shares.lock().await; let mut active_shares = self.active_shares.lock().await;
if active_shares.contains_key(&transfer_id) { if active_shares.contains_key(&transfer_id) {
let local_id = self.repository.transfer_local_id(transfer_id).await?;
self.repository self.repository
.transition_transfer_status( .transition_transfer_status(
transfer_id, transfer_id,
@@ -53,6 +54,7 @@ impl CoreInner {
drop(active_shares); drop(active_shares);
self.unregister_transfer_hashes(transfer_id).await; self.unregister_transfer_hashes(transfer_id).await;
self.access_policy.remove_transfer(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!({})); self.emit_transfer(transfer_id, "send", "lifecycle", "share-stopped", json!({}));
return Ok(()); return Ok(());
} }
@@ -105,6 +107,12 @@ impl CoreInner {
self.active_shares.lock().await.remove(&transfer_id); self.active_shares.lock().await.remove(&transfer_id);
self.unregister_transfer_hashes(transfer_id).await; self.unregister_transfer_hashes(transfer_id).await;
self.access_policy.remove_transfer(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 // Events are persisted asynchronously. Drain events emitted before this
// request so none can be written back after the transfer is deleted. // request so none can be written back after the transfer is deleted.
self.event_hub.flush().await; self.event_hub.flush().await;

View File

@@ -12,6 +12,7 @@ mod lifecycle;
mod provider; mod provider;
mod receive; mod receive;
mod share; mod share;
mod storage;
pub use facade::VnidropCore; pub use facade::VnidropCore;
@@ -20,15 +21,19 @@ use std::{
path::PathBuf, path::PathBuf,
str::FromStr, str::FromStr,
sync::{atomic::AtomicBool, Arc}, sync::{atomic::AtomicBool, Arc},
time::Duration,
}; };
use anyhow::Result; use anyhow::Result;
use futures_lite::StreamExt as _;
use iroh::{endpoint::presets, protocol::Router, Endpoint}; use iroh::{endpoint::presets, protocol::Router, Endpoint};
use iroh_blobs::{ use iroh_blobs::{
api::TempTag,
format::collection::Collection, format::collection::Collection,
provider::events::{EventMask, EventSender}, provider::events::{EventMask, EventSender},
store::fs::FsStore, store::{
fs::{options::Options as FsStoreOptions, FsStore},
GcConfig,
},
BlobsProtocol, Hash, BlobsProtocol, Hash,
}; };
use serde_json::json; use serde_json::json;
@@ -52,6 +57,7 @@ use crate::{
/// Owns the Iroh endpoint, blob store, transfer history, and byte streaming. /// Owns the Iroh endpoint, blob store, transfer history, and byte streaming.
/// Kotlin owns app lifecycle and platform file picking. /// Kotlin owns app lifecycle and platform file picking.
pub(super) struct CoreInner { pub(super) struct CoreInner {
pub(super) app_data_dir: PathBuf,
pub(super) endpoint: Endpoint, pub(super) endpoint: Endpoint,
pub(super) router: Router, pub(super) router: Router,
pub(super) store: FsStore, pub(super) store: FsStore,
@@ -64,10 +70,8 @@ pub(super) struct CoreInner {
/// Sync mutex so cancel can remove + signal without awaiting (and without /// Sync mutex so cancel can remove + signal without awaiting (and without
/// holding a Tokio lock across repository I/O). /// holding a Tokio lock across repository I/O).
pub(super) active_transfers: std::sync::Mutex<HashMap<u64, ActiveTransfer>>, pub(super) active_transfers: std::sync::Mutex<HashMap<u64, ActiveTransfer>>,
// Newly imported shares retain a TempTag for the lifetime of this process. // Active shares are protected by persistent Iroh tags.
// Restored shares have no in-memory tag, but remain tracked so they can be pub(super) active_shares: TokioMutex<HashMap<u64, ()>>,
// counted and explicitly revoked after a restart.
pub(super) active_shares: TokioMutex<HashMap<u64, Option<TempTag>>>,
/// Content hash → active share transfer ids (root and collection members). /// Content hash → active share transfer ids (root and collection members).
/// Multiple transfers can share the same content-addressed hash. /// Multiple transfers can share the same content-addressed hash.
pub(super) hash_to_transfer: TokioMutex<HashMap<String, HashSet<u64>>>, pub(super) hash_to_transfer: TokioMutex<HashMap<String, HashSet<u64>>>,
@@ -92,7 +96,12 @@ impl CoreInner {
let secret_key = load_or_create_secret(&app_data_dir).await?; let secret_key = load_or_create_secret(&app_data_dir).await?;
let repository = Repository::open(&app_data_dir).await?; let repository = Repository::open(&app_data_dir).await?;
let store_root = app_data_dir.join("blobs"); 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) let endpoint = Endpoint::builder(presets::N0)
.secret_key(secret_key) .secret_key(secret_key)
.bind() .bind()
@@ -135,6 +144,7 @@ impl CoreInner {
// Register root + every collection member so child gets stay under ACL. // Register root + every collection member so child gets stay under ACL.
let mut restored_hashes: HashMap<String, HashSet<u64>> = HashMap::new(); let mut restored_hashes: HashMap<String, HashSet<u64>> = HashMap::new();
let mut restored_active_shares = HashMap::new(); let mut restored_active_shares = HashMap::new();
let mut active_tag_names = HashSet::new();
for share in repository.list_active_shares().await? { for share in repository.list_active_shares().await? {
let transfer_id = share.transfer_id; let transfer_id = share.transfer_id;
let Ok(root_hash) = Hash::from_str(&share.content_hash) else { let Ok(root_hash) = Hash::from_str(&share.content_hash) else {
@@ -176,6 +186,12 @@ impl CoreInner {
); );
continue; 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 restored_hashes
.entry(root_hash.to_string()) .entry(root_hash.to_string())
.or_default() .or_default()
@@ -186,11 +202,19 @@ impl CoreInner {
.or_default() .or_default()
.insert(transfer_id); .insert(transfer_id);
} }
restored_active_shares.insert(transfer_id, None); restored_active_shares.insert(transfer_id, ());
access_policy access_policy
.set_mode(transfer_id, mode_from_storage(&share.access_mode)) .set_mode(transfer_id, mode_from_storage(&share.access_mode))
.await; .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( let approval = ApprovalService::new(
repository.clone(), repository.clone(),
event_hub.clone(), event_hub.clone(),
@@ -205,6 +229,7 @@ impl CoreInner {
.spawn(); .spawn();
let inner = Arc::new(Self { let inner = Arc::new(Self {
app_data_dir,
endpoint, endpoint,
router, router,
store, store,
@@ -277,3 +302,7 @@ impl CoreInner {
.await .await
} }
} }
pub(super) fn share_tag_name(local_id: &str) -> String {
format!("vnidrop/share/{local_id}")
}

View File

@@ -17,13 +17,16 @@ use tokio::sync::oneshot;
use super::{ActiveTransfer, CoreInner}; use super::{ActiveTransfer, CoreInner};
use crate::{ use crate::{
access_policy::mode_to_storage, access_policy::mode_to_storage,
api::{ReceiveOutputSink, TransferAccessMode, TransferMetadata}, api::{
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedLocatorKind, TransferAccessMode,
TransferMetadata,
},
filesystem::{ filesystem::{
validated_relative_string, wait_for_writer, write_stream_to_blocking_writer, validated_relative_string, wait_for_writer, write_stream_to_blocking_writer,
AtomicOutputFile, AtomicOutputFile,
}, },
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, HandshakeService}, handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, HandshakeService},
repository::TransferUpsert, repository::{ReceivedArtifactInsert, TransferUpsert},
ticket::{parse_transfer_ticket_with_limits, ParsedTransferTicket}, ticket::{parse_transfer_ticket_with_limits, ParsedTransferTicket},
transfer_state::{TransferDirection, TransferStatus}, transfer_state::{TransferDirection, TransferStatus},
}; };
@@ -31,6 +34,13 @@ use crate::{
pub(super) enum ReceiveTarget { pub(super) enum ReceiveTarget {
Directory(PathBuf), Directory(PathBuf),
OutputSink(Arc<dyn ReceiveOutputSink>), OutputSink(Arc<dyn ReceiveOutputSink>),
OutputSinkV2(Arc<dyn ReceiveOutputSinkV2>),
}
#[derive(Clone, Copy)]
struct ReceivedTransfer<'a> {
protocol_id: u64,
local_id: &'a str,
} }
pub(super) struct OutputSinkFile<'a> { 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<Self> {
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<u8>) -> Result<()> {
self.sink
.write_chunk(self.relative_path.clone(), bytes)
.map_err(|error| anyhow::anyhow!(error.to_string()))
}
fn finish(mut self) -> Result<crate::api::PublishedOutput> {
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 { impl CoreInner {
pub(super) async fn receive( pub(super) async fn receive(
self: &Arc<Self>, self: &Arc<Self>,
@@ -110,6 +165,20 @@ impl CoreInner {
.await .await
} }
pub(super) async fn receive_with_output_sink_v2(
self: &Arc<Self>,
ticket: String,
output_sink: Arc<dyn ReceiveOutputSinkV2>,
receiver_name: Option<String>,
) -> Result<()> {
self.receive_to_target(
ticket,
ReceiveTarget::OutputSinkV2(output_sink),
receiver_name,
)
.await
}
pub(super) async fn receive_to_target( pub(super) async fn receive_to_target(
self: &Arc<Self>, self: &Arc<Self>,
ticket: String, ticket: String,
@@ -242,9 +311,15 @@ impl CoreInner {
json!({ "total_files": total_files, "total_size": total_size }), 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 get = self.store.remote().fetch(connection, hash_and_format);
let mut stream = get.stream(); 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 { match item {
GetProgressItem::Progress(downloaded) => { GetProgressItem::Progress(downloaded) => {
self.emit_transfer( self.emit_transfer(
@@ -270,6 +345,7 @@ impl CoreInner {
TransferStatus::Done, TransferStatus::Done,
) )
.await?; .await?;
drop(download_tag);
self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({})); self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({}));
let sender_transfer_id = delivery_receipt.transfer_id; let sender_transfer_id = delivery_receipt.transfer_id;
let client = HandshakeService::client(self.endpoint.clone(), sender_addr); let client = HandshakeService::client(self.endpoint.clone(), sender_addr);
@@ -393,11 +469,16 @@ impl CoreInner {
target: ReceiveTarget, target: ReceiveTarget,
collection: Collection, collection: Collection,
) -> Result<()> { ) -> 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() { for (i, (name, hash)) in collection.iter().enumerate() {
match &target { match &target {
ReceiveTarget::Directory(output_dir) => { ReceiveTarget::Directory(output_dir) => {
self.export_blob_to_directory( self.export_blob_to_directory(
transfer_id, received_transfer,
total_files, total_files,
i as u64, i as u64,
output_dir, output_dir,
@@ -417,14 +498,25 @@ impl CoreInner {
) )
.await?; .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(()) Ok(())
} }
pub(super) async fn export_blob_to_directory( async fn export_blob_to_directory(
&self, &self,
transfer_id: u64, transfer: ReceivedTransfer<'_>,
total_files: u64, total_files: u64,
current_file_index: u64, current_file_index: u64,
output_dir: &Path, output_dir: &Path,
@@ -458,7 +550,7 @@ impl CoreInner {
.await .await
.map_err(|_| anyhow::anyhow!("export writer closed for {relative_path}"))?; .map_err(|_| anyhow::anyhow!("export writer closed for {relative_path}"))?;
self.emit_transfer( self.emit_transfer(
transfer_id, transfer.protocol_id,
"receive", "receive",
"export", "export",
"progress", "progress",
@@ -482,7 +574,18 @@ impl CoreInner {
.map_err(|_| anyhow::anyhow!("export writer closed for {relative_path}"))?; .map_err(|_| anyhow::anyhow!("export writer closed for {relative_path}"))?;
let writer = wait_for_writer(writer_task).await??; let writer = wait_for_writer(writer_task).await??;
tokio::task::spawn_blocking(move || writer.sync_all()).await??; tokio::task::spawn_blocking(move || writer.sync_all()).await??;
let locator = pending_file.target().to_string_lossy().to_string();
pending_file.commit()?; 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(()) Ok(())
} }
@@ -545,4 +648,71 @@ impl CoreInner {
output_file.finish()?; output_file.finish()?;
Ok(()) 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(())
}
} }

View File

@@ -12,7 +12,7 @@ use n0_future::BufferedStreamExt;
use serde_json::json; use serde_json::json;
use tokio::sync::oneshot; use tokio::sync::oneshot;
use super::{ActiveTransfer, CoreInner}; use super::{share_tag_name, ActiveTransfer, CoreInner};
use crate::{ use crate::{
access_policy::mode_to_storage, access_policy::mode_to_storage,
api::TransferMetadata, api::TransferMetadata,
@@ -142,11 +142,24 @@ impl CoreInner {
.encode() .encode()
.context("failed to encode VniDrop transfer ticket")?; .context("failed to encode VniDrop transfer ticket")?;
let content_hash = import.root_hash.to_string(); 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. // Persist the completed share before exposing it through the provider.
// The remaining in-memory registrations are infallible and can be // The remaining in-memory registrations are infallible and can be
// reconstructed from SQLite if the process exits immediately after. // reconstructed from SQLite if the process exits immediately after.
self.repository if let Err(error) = self
.repository
.complete_share_import(TransferUpsert { .complete_share_import(TransferUpsert {
transfer_id: metadata.transfer_id, transfer_id: metadata.transfer_id,
peer_id: None, peer_id: None,
@@ -159,7 +172,11 @@ impl CoreInner {
total_size: import.total_size, total_size: import.total_size,
access_mode: mode_to_storage(&access_mode), 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 // Map root + every collection member so provider ACL cannot fail-open
// on child blob hashes that are not the collection root. // on child blob hashes that are not the collection root.
self.register_share_hashes( self.register_share_hashes(
@@ -173,7 +190,8 @@ impl CoreInner {
self.active_shares self.active_shares
.lock() .lock()
.await .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. // Tickets are capabilities: never persist the full string in events.
self.emit_transfer( self.emit_transfer(

View File

@@ -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<CoreStorageUsage> {
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<CoreStorageUsage> {
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<u64> {
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<u64> {
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),
}
}

View File

@@ -1,6 +1,6 @@
use crate::{ use crate::{
api::CoreEvent, api::{CoreEvent, ReceivedLocatorKind},
repository::{ReceiverRequestInsert, Repository, TransferUpsert}, repository::{ReceivedArtifactInsert, ReceiverRequestInsert, Repository, TransferUpsert},
transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus}, 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] #[tokio::test]
async fn persists_transfers_and_events_across_reopen() { async fn persists_transfers_and_events_across_reopen() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.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 repository
.insert_transfer(transfer( .insert_transfer(transfer(
7, 7,
@@ -491,7 +527,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() {
pool.close().await; pool.close().await;
let repository = Repository::open(temp.path()).await.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);
let stored = repository.list_transfers().await.unwrap().remove(0); let stored = repository.list_transfers().await.unwrap().remove(0);
assert_eq!(stored.transfer_id, 7); assert_eq!(stored.transfer_id, 7);
assert_eq!(stored.local_id, "legacy-7-send"); assert_eq!(stored.local_id, "legacy-7-send");

View File

@@ -3,6 +3,8 @@ mod support;
use std::sync::{Arc, Condvar, Mutex}; use std::sync::{Arc, Condvar, Mutex};
use std::time::Duration; use std::time::Duration;
use futures_lite::StreamExt as _;
use iroh_blobs::store::fs::FsStore;
use support::{share_path, CoreGuard, RecordingSink, TestNode}; use support::{share_path, CoreGuard, RecordingSink, TestNode};
use vnidrop::{ use vnidrop::{
CoreEvent, CoreEventSink, CoreLimits, ShareMetadataInput, ShareSource, SourceKind, 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); 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] #[test]
fn persisted_share_is_recovered_and_can_be_stopped_after_restart() { fn persisted_share_is_recovered_and_can_be_stopped_after_restart() {
let source_dir = tempfile::tempdir().unwrap(); let source_dir = tempfile::tempdir().unwrap();

View File

@@ -5,9 +5,36 @@ use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use support::{ 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] #[test]
fn exports_nested_files_to_output_sink() { fn exports_nested_files_to_output_sink() {
let source_dir = tempfile::tempdir().unwrap(); let source_dir = tempfile::tempdir().unwrap();

View File

@@ -14,8 +14,9 @@ use std::{
}; };
use vnidrop::{ use vnidrop::{
CoreEvent, CoreEventSink, CoreLimits, ReceiveOutputSink, ReceiverRequest, ShareMetadataInput, CoreEvent, CoreEventSink, CoreLimits, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2,
ShareResult, ShareSource, SourceKind, TransferAccessMode, VnidropCore, VnidropError, ReceivedLocatorKind, ReceiverRequest, ShareMetadataInput, ShareResult, ShareSource, SourceKind,
TransferAccessMode, VnidropCore, VnidropError,
}; };
#[derive(Default)] #[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<u8>) -> Result<(), VnidropError> {
ReceiveOutputSink::write_chunk(self, relative_path, bytes)
}
fn finish_file(&self, relative_path: String) -> Result<PublishedOutput, VnidropError> {
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( pub fn share_path(
sender: &VnidropCore, sender: &VnidropCore,
source: &Path, source: &Path,
@@ -297,6 +320,23 @@ pub fn receive_with_sink_response(
handle.join().unwrap() handle.join().unwrap()
} }
pub fn receive_with_sink_v2_response(
sender: &VnidropCore,
transfer_id: u64,
receiver: Arc<VnidropCore>,
ticket: String,
output_sink: Arc<dyn ReceiveOutputSinkV2>,
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) { fn respond_to_pending_request(sender: &VnidropCore, transfer_id: u64, accepted: bool) {
let request = wait_for_receiver_request(sender, transfer_id); let request = wait_for_receiver_request(sender, transfer_id);
sender sender

View File

@@ -38,6 +38,20 @@ fn transfers_file_between_two_cores() {
received.peer_id.as_deref(), received.peer_id.as_deref(),
Some(sender.core.status().endpoint_id.as_str()) 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] #[test]

View File

@@ -2926,15 +2926,29 @@
"storage_delete_transfers_description": { "storage_delete_transfers_description": {
"context": "Settings > Storage: confirmation body for deleting all transfer records.", "context": "Settings > Storage: confirmation body for deleting all transfer records.",
"translations": { "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 cant be undone.", "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 cant 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.", "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 nest plus cessaire est récupéré automatiquement, ce qui peut prendre un peu de temps. 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.", "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 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.", "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": "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.", "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 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.", "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": "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ąć.", "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. Let op: de overdrachtsengine bewaart de opgeslagen inhoud, dus de overdrachtsgegevens nemen mogelijk niet af. Dit kan niet ongedaan worden gemaakt.", "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": "Это удалит из истории все записи об отправленных и полученных передачах. Ваши полученные файлы не удаляются. Примечание: механизм передачи сохраняет своё содержимое, поэтому объём данных передачи может не уменьшиться. Это действие нельзя отменить." "ru": "Это удалит из истории все записи об отправленных и полученных передачах. Полученные файлы не удаляются. Кэшированное общее содержимое, которое больше не требуется, освобождается автоматически; это может занять некоторое время. Это действие нельзя отменить."
}
},
"storage_app_data": {
"context": "Settings > Storage: label for non-transfer application data.",
"translations": {
"en": "App data",
"fr": "Données de lapp",
"es": "Datos de la aplicación",
"it": "Dati dellapp",
"de": "App-Daten",
"pt": "Dados da aplicação",
"pl": "Dane aplikacji",
"nl": "Appgegevens",
"ru": "Данные приложения"
} }
}, },
"storage_deleting": { "storage_deleting": {
@@ -2954,15 +2968,15 @@
"storage_footer": { "storage_footer": {
"context": "Settings > Storage: footer explaining how storage is managed.", "context": "Settings > Storage: footer explaining how storage is managed.",
"translations": { "translations": {
"en": "Transfer data is managed by the transfer engine — your history plus the content of files youve 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.", "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 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.", "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 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í.", "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 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.", "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 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.", "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 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.", "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": "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.", "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 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.", "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": "Данными передачи управляет механизм передачи — ваша история плюс содержимое файлов, которыми вы поделились. Кнопка ниже удаляет записи о передачах; механизм сохраняет своё содержимое, поэтому это значение может не уменьшиться. Полученные файлы — это загруженные вами файлы, и они никогда не удаляются здесь." "ru": "Данные передачи включают историю и кэшированное содержимое активных раздач. Ненужный кэш освобождается автоматически после удаления записей; это может занять некоторое время. Полученные файлы учитываются в этой сводке, но никогда не удаляются здесь."
} }
}, },
"storage_received_files": { "storage_received_files": {

View File

@@ -12,7 +12,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri 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.File
import java.io.OutputStream import java.io.OutputStream
import java.net.URLConnection import java.net.URLConnection
@@ -54,7 +56,52 @@ private class AndroidFileSystemService(
ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value) ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value)
} }
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = override suspend fun inspectReceivedArtifacts(artifacts: List<ReceivedArtifactModel>): 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) { when (folder.kind) {
ReceiveFolderKind.AndroidPublicDownloads -> AndroidMediaStoreDownloadsSink(context) ReceiveFolderKind.AndroidPublicDownloads -> AndroidMediaStoreDownloadsSink(context)
ReceiveFolderKind.AndroidTreeUri -> AndroidTreeReceiveOutputSink(context, folder.value.toUri()) ReceiveFolderKind.AndroidTreeUri -> AndroidTreeReceiveOutputSink(context, folder.value.toUri())
@@ -209,7 +256,7 @@ private fun Context.expandShareDirectory(folder: PickedShareFile): List<PickedSh
*/ */
private class AndroidMediaStoreDownloadsSink( private class AndroidMediaStoreDownloadsSink(
private val context: Context, private val context: Context,
) : ReceiveOutputSink { ) : ReceiveOutputSinkV2 {
private data class PendingDocument( private data class PendingDocument(
val stream: OutputStream, val stream: OutputStream,
val uri: Uri, val uri: Uri,
@@ -254,7 +301,7 @@ private class AndroidMediaStoreDownloadsSink(
document.stream.write(bytes) document.stream.write(bytes)
} }
override fun finishFile(relativePath: String) { override fun finishFile(relativePath: String): PublishedOutput {
val document = pending.remove(relativePath) ?: error("Output stream is not open for $relativePath") val document = pending.remove(relativePath) ?: error("Output stream is not open for $relativePath")
try { try {
document.stream.flush() document.stream.flush()
@@ -269,6 +316,7 @@ private class AndroidMediaStoreDownloadsSink(
resolver.delete(document.uri, null, null) resolver.delete(document.uri, null, null)
throw error throw error
} }
return PublishedOutput(ReceivedLocatorKind.ANDROID_MEDIA_STORE, document.uri.toString())
} }
override fun abortFile(relativePath: String, reason: String) { override fun abortFile(relativePath: String, reason: String) {
@@ -331,7 +379,7 @@ private class AndroidMediaStoreDownloadsSink(
private class AndroidTreeReceiveOutputSink( private class AndroidTreeReceiveOutputSink(
private val context: Context, private val context: Context,
private val treeUri: Uri, private val treeUri: Uri,
) : ReceiveOutputSink { ) : ReceiveOutputSinkV2 {
private data class PendingDocument( private data class PendingDocument(
val stream: OutputStream, val stream: OutputStream,
val temporaryUri: Uri, val temporaryUri: Uri,
@@ -364,18 +412,19 @@ private class AndroidTreeReceiveOutputSink(
document.stream.write(bytes) document.stream.write(bytes)
} }
override fun finishFile(relativePath: String) { override fun finishFile(relativePath: String): PublishedOutput {
val document = pending.remove(relativePath) ?: error("Output stream is not open for $relativePath") val document = pending.remove(relativePath) ?: error("Output stream is not open for $relativePath")
try { try {
document.stream.close() document.stream.close()
check(findChild(document.parentUri, document.finalName) == null) { "Destination already exists: $relativePath" } check(findChild(document.parentUri, document.finalName) == null) { "Destination already exists: $relativePath" }
checkNotNull( val finalUri = checkNotNull(
DocumentsContract.renameDocument( DocumentsContract.renameDocument(
context.contentResolver, context.contentResolver,
document.temporaryUri, document.temporaryUri,
document.finalName, document.finalName,
), ),
) { "Could not commit received file $relativePath" } ) { "Could not commit received file $relativePath" }
return PublishedOutput(ReceivedLocatorKind.ANDROID_DOCUMENT, finalUri.toString())
} catch (error: Throwable) { } catch (error: Throwable) {
runCatching { document.stream.close() } runCatching { document.stream.close() }
DocumentsContract.deleteDocument(context.contentResolver, document.temporaryUri) DocumentsContract.deleteDocument(context.contentResolver, document.temporaryUri)
@@ -441,3 +490,8 @@ private fun requireSafeRelativePathParts(relativePath: String): List<String> {
} }
return parts 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() }

View File

@@ -200,9 +200,10 @@
<string name="status_stopped">Beendet</string> <string name="status_stopped">Beendet</string>
<string name="storage_calculating">Wird berechnet…</string> <string name="storage_calculating">Wird berechnet…</string>
<string name="storage_delete_transfers">Alle Übertragungen löschen</string> <string name="storage_delete_transfers">Alle Übertragungen löschen</string>
<string name="storage_delete_transfers_description">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.</string> <string name="storage_delete_transfers_description">Dadurch werden alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf gelöscht. Ihre empfangenen Dateien werden nicht gelöscht. Nicht mehr benötigte zwischengespeicherte freigegebene Inhalte werden automatisch bereinigt; dies kann etwas dauern. Dies kann nicht rückgängig gemacht werden.</string>
<string name="storage_app_data">App-Daten</string>
<string name="storage_deleting">Wird gelöscht…</string> <string name="storage_deleting">Wird gelöscht…</string>
<string name="storage_footer">Übertragungsdaten 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.</string> <string name="storage_footer">Übertragungsdaten umfassen Ihren Verlauf und zwischengespeicherte Inhalte aktiver Freigaben. Nicht mehr benötigter Cache wird nach dem Löschen der Datensätze automatisch bereinigt; dies kann etwas dauern. Empfangene Dateien werden für diese Übersicht erfasst, aber hier niemals gelöscht.</string>
<string name="storage_received_files">Empfangene Dateien</string> <string name="storage_received_files">Empfangene Dateien</string>
<string name="storage_temporary">Temporäre Dateien</string> <string name="storage_temporary">Temporäre Dateien</string>
<string name="storage_title">Speicher</string> <string name="storage_title">Speicher</string>

View File

@@ -200,9 +200,10 @@
<string name="status_stopped">Detenido</string> <string name="status_stopped">Detenido</string>
<string name="storage_calculating">Calculando…</string> <string name="storage_calculating">Calculando…</string>
<string name="storage_delete_transfers">Eliminar todas las transferencias</string> <string name="storage_delete_transfers">Eliminar todas las transferencias</string>
<string name="storage_delete_transfers_description">Esto borra 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.</string> <string name="storage_delete_transfers_description">Esto borra de su historial todos los registros de transferencias enviadas y recibidas. Sus archivos recibidos no se eliminan. El contenido compartido en caché que ya no se necesita se recupera automáticamente, lo que puede tardar un poco. Esto no se puede deshacer.</string>
<string name="storage_app_data">Datos de la aplicación</string>
<string name="storage_deleting">Eliminando…</string> <string name="storage_deleting">Eliminando…</string>
<string name="storage_footer">Los datos de transferencia 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í.</string> <string name="storage_footer">Los datos de transferencia incluyen su historial y el contenido en caché de los recursos compartidos activos. La caché innecesaria se recupera automáticamente tras eliminar los registros, lo que puede tardar un poco. Los archivos recibidos se registran para este resumen, pero nunca se eliminan aquí.</string>
<string name="storage_received_files">Archivos recibidos</string> <string name="storage_received_files">Archivos recibidos</string>
<string name="storage_temporary">Archivos temporales</string> <string name="storage_temporary">Archivos temporales</string>
<string name="storage_title">Almacenamiento</string> <string name="storage_title">Almacenamiento</string>

View File

@@ -200,9 +200,10 @@
<string name="status_stopped">Arrêté</string> <string name="status_stopped">Arrêté</string>
<string name="storage_calculating">Calcul…</string> <string name="storage_calculating">Calcul…</string>
<string name="storage_delete_transfers">Supprimer tous les transferts</string> <string name="storage_delete_transfers">Supprimer tous les transferts</string>
<string name="storage_delete_transfers_description">Cela efface 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.</string> <string name="storage_delete_transfers_description">Cela efface de votre historique tous les enregistrements de transferts envoyés et reçus. Vos fichiers reçus ne sont pas supprimés. Le contenu partagé mis en cache qui nest plus cessaire est récupéré automatiquement, ce qui peut prendre un peu de temps. Cette action est irréversible.</string>
<string name="storage_app_data">Données de lapp</string>
<string name="storage_deleting">Suppression…</string> <string name="storage_deleting">Suppression…</string>
<string name="storage_footer">Les données de transfert 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.</string> <string name="storage_footer">Les données de transfert comprennent votre historique et le contenu mis en cache pour les partages actifs. Le cache inutile est récupéré automatiquement après la suppression des enregistrements, ce qui peut prendre un peu de temps. Les fichiers reçus sont suivis pour ce récapitulatif, mais ne sont jamais supprimés ici.</string>
<string name="storage_received_files">Fichiers reçus</string> <string name="storage_received_files">Fichiers reçus</string>
<string name="storage_temporary">Fichiers temporaires</string> <string name="storage_temporary">Fichiers temporaires</string>
<string name="storage_title">Stockage</string> <string name="storage_title">Stockage</string>

View File

@@ -200,9 +200,10 @@
<string name="status_stopped">Interrotto</string> <string name="status_stopped">Interrotto</string>
<string name="storage_calculating">Calcolo…</string> <string name="storage_calculating">Calcolo…</string>
<string name="storage_delete_transfers">Elimina tutti i trasferimenti</string> <string name="storage_delete_transfers">Elimina tutti i trasferimenti</string>
<string name="storage_delete_transfers_description">Questo cancella dalla cronologia tutti i record dei trasferimenti inviati e ricevuti. I 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.</string> <string name="storage_delete_transfers_description">Questo cancella dalla cronologia tutti i record dei trasferimenti inviati e ricevuti. I file ricevuti non vengono eliminati. Il contenuto condiviso nella cache che non serve più viene recuperato automaticamente, operazione che può richiedere un po di tempo. Questa azione non può essere annullata.</string>
<string name="storage_app_data">Dati dellapp</string>
<string name="storage_deleting">Eliminazione…</string> <string name="storage_deleting">Eliminazione…</string>
<string name="storage_footer">I dati di trasferimento 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.</string> <string name="storage_footer">I dati di trasferimento includono la cronologia e il contenuto nella cache per le condivisioni attive. La cache non necessaria viene recuperata automaticamente dopo la rimozione dei record, operazione che può richiedere un po di tempo. I file ricevuti vengono monitorati per questo riepilogo, ma non sono mai eliminati qui.</string>
<string name="storage_received_files">File ricevuti</string> <string name="storage_received_files">File ricevuti</string>
<string name="storage_temporary">File temporanei</string> <string name="storage_temporary">File temporanei</string>
<string name="storage_title">Archiviazione</string> <string name="storage_title">Archiviazione</string>

View File

@@ -200,9 +200,10 @@
<string name="status_stopped">Gestopt</string> <string name="status_stopped">Gestopt</string>
<string name="storage_calculating">Berekenen…</string> <string name="storage_calculating">Berekenen…</string>
<string name="storage_delete_transfers">Alle overdrachten verwijderen</string> <string name="storage_delete_transfers">Alle overdrachten verwijderen</string>
<string name="storage_delete_transfers_description">Hiermee worden alle records van verzonden en ontvangen overdrachten uit uw geschiedenis gewist. Uw ontvangen bestanden worden niet verwijderd. Let op: de overdrachtsengine bewaart de opgeslagen inhoud, dus de overdrachtsgegevens nemen mogelijk niet af. Dit kan niet ongedaan worden gemaakt.</string> <string name="storage_delete_transfers_description">Hiermee worden alle records van verzonden en ontvangen overdrachten uit uw geschiedenis gewist. Uw ontvangen bestanden worden niet verwijderd. Gedeelde inhoud in de cache die niet meer nodig is, wordt automatisch opgeruimd; dit kan enige tijd duren. Dit kan niet ongedaan worden gemaakt.</string>
<string name="storage_app_data">Appgegevens</string>
<string name="storage_deleting">Verwijderen…</string> <string name="storage_deleting">Verwijderen…</string>
<string name="storage_footer">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.</string> <string name="storage_footer">Overdrachtsgegevens omvatten uw geschiedenis en inhoud in de cache voor actieve shares. Onnodige cache wordt automatisch opgeruimd nadat overdrachtsrecords zijn verwijderd; dit kan enige tijd duren. Ontvangen bestanden worden voor dit overzicht bijgehouden, maar hier nooit verwijderd.</string>
<string name="storage_received_files">Ontvangen bestanden</string> <string name="storage_received_files">Ontvangen bestanden</string>
<string name="storage_temporary">Tijdelijke bestanden</string> <string name="storage_temporary">Tijdelijke bestanden</string>
<string name="storage_title">Opslag</string> <string name="storage_title">Opslag</string>

View File

@@ -200,9 +200,10 @@
<string name="status_stopped">Zatrzymany</string> <string name="status_stopped">Zatrzymany</string>
<string name="storage_calculating">Obliczanie…</string> <string name="storage_calculating">Obliczanie…</string>
<string name="storage_delete_transfers">Usuń wszystkie transfery</string> <string name="storage_delete_transfers">Usuń wszystkie transfery</string>
<string name="storage_delete_transfers_description">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ąć.</string> <string name="storage_delete_transfers_description">Spowoduje to usunięcie z historii wszystkich rekordów wysłanych i odebranych transferów. Odebrane pliki nie zostaną usunięte. Niepotrzebna już zawartość udostępniona w pamięci podręcznej jest odzyskiwana automatycznie, co może chwilę potrwać. Tej operacji nie można cofnąć.</string>
<string name="storage_app_data">Dane aplikacji</string>
<string name="storage_deleting">Usuwanie…</string> <string name="storage_deleting">Usuwanie…</string>
<string name="storage_footer">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.</string> <string name="storage_footer">Dane transferu obejmują historię oraz zawartość w pamięci podręcznej dla aktywnych udostępnień. Niepotrzebna pamięć podręczna jest odzyskiwana automatycznie po usunięciu rekordów, co może chwilę potrwać. Odebrane pliki są śledzone na potrzeby tego podsumowania, ale nigdy nie są tu usuwane.</string>
<string name="storage_received_files">Odebrane pliki</string> <string name="storage_received_files">Odebrane pliki</string>
<string name="storage_temporary">Pliki tymczasowe</string> <string name="storage_temporary">Pliki tymczasowe</string>
<string name="storage_title">Pamięć</string> <string name="storage_title">Pamięć</string>

View File

@@ -200,9 +200,10 @@
<string name="status_stopped">Parada</string> <string name="status_stopped">Parada</string>
<string name="storage_calculating">A calcular…</string> <string name="storage_calculating">A calcular…</string>
<string name="storage_delete_transfers">Eliminar todas as transferências</string> <string name="storage_delete_transfers">Eliminar todas as transferências</string>
<string name="storage_delete_transfers_description">Isto elimina do 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.</string> <string name="storage_delete_transfers_description">Isto elimina do histórico todos os registos de transferências enviadas e recebidas. Os ficheiros recebidos não são eliminados. O conteúdo partilhado em cache que já não é necessário é recuperado automaticamente, o que pode demorar algum tempo. Esta ação não pode ser anulada.</string>
<string name="storage_app_data">Dados da aplicação</string>
<string name="storage_deleting">A eliminar…</string> <string name="storage_deleting">A eliminar…</string>
<string name="storage_footer">Os dados de transferência 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.</string> <string name="storage_footer">Os dados de transferência incluem o histórico e o conteúdo em cache das partilhas ativas. A cache desnecessária é recuperada automaticamente após a remoção dos registos, o que pode demorar algum tempo. Os ficheiros recebidos são acompanhados para este resumo, mas nunca são eliminados aqui.</string>
<string name="storage_received_files">Ficheiros recebidos</string> <string name="storage_received_files">Ficheiros recebidos</string>
<string name="storage_temporary">Ficheiros temporários</string> <string name="storage_temporary">Ficheiros temporários</string>
<string name="storage_title">Armazenamento</string> <string name="storage_title">Armazenamento</string>

View File

@@ -200,9 +200,10 @@
<string name="status_stopped">Остановлено</string> <string name="status_stopped">Остановлено</string>
<string name="storage_calculating">Вычисление…</string> <string name="storage_calculating">Вычисление…</string>
<string name="storage_delete_transfers">Удалить все передачи</string> <string name="storage_delete_transfers">Удалить все передачи</string>
<string name="storage_delete_transfers_description">Это удалит из истории все записи об отправленных и полученных передачах. Ваши полученные файлы не удаляются. Примечание: механизм передачи сохраняет своё содержимое, поэтому объём данных передачи может не уменьшиться. Это действие нельзя отменить.</string> <string name="storage_delete_transfers_description">Это удалит из истории все записи об отправленных и полученных передачах. Полученные файлы не удаляются. Кэшированное общее содержимое, которое больше не требуется, освобождается автоматически; это может занять некоторое время. Это действие нельзя отменить.</string>
<string name="storage_app_data">Данные приложения</string>
<string name="storage_deleting">Удаление…</string> <string name="storage_deleting">Удаление…</string>
<string name="storage_footer">Данными передачи управляет механизм передачи — ваша история плюс содержимое файлов, которыми вы поделились. Кнопка ниже удаляет записи о передачах; механизм сохраняет своё содержимое, поэтому это значение может не уменьшиться. Полученные файлы — это загруженные вами файлы, и они никогда не удаляются здесь.</string> <string name="storage_footer">Данные передачи включают историю и кэшированное содержимое активных раздач. Ненужный кэш освобождается автоматически после удаления записей; это может занять некоторое время. Полученные файлы учитываются в этой сводке, но никогда не удаляются здесь.</string>
<string name="storage_received_files">Полученные файлы</string> <string name="storage_received_files">Полученные файлы</string>
<string name="storage_temporary">Временные файлы</string> <string name="storage_temporary">Временные файлы</string>
<string name="storage_title">Хранилище</string> <string name="storage_title">Хранилище</string>

View File

@@ -200,9 +200,10 @@
<string name="status_stopped">Stopped</string> <string name="status_stopped">Stopped</string>
<string name="storage_calculating">Calculating…</string> <string name="storage_calculating">Calculating…</string>
<string name="storage_delete_transfers">Delete all transfers</string> <string name="storage_delete_transfers">Delete all transfers</string>
<string name="storage_delete_transfers_description">This clears all sent and received transfer records from your history. Your received files are not deleted. Note: the transfer engine keeps its stored content, so Transfer data may not decrease. This cant be undone.</string> <string name="storage_delete_transfers_description">This clears all sent and received transfer records from your history. Your received files are not deleted. Cached shared content that is no longer needed is reclaimed automatically, which may take a little time. This cant be undone.</string>
<string name="storage_app_data">App data</string>
<string name="storage_deleting">Deleting…</string> <string name="storage_deleting">Deleting…</string>
<string name="storage_footer">Transfer data is managed by the transfer engine — your history plus the content of files youve 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.</string> <string name="storage_footer">Transfer data includes your history and cached content for active shares. Unneeded cache is reclaimed automatically after transfer records are removed, which may take a little time. Received files are tracked for this summary but are never deleted here.</string>
<string name="storage_received_files">Received files</string> <string name="storage_received_files">Received files</string>
<string name="storage_temporary">Temporary files</string> <string name="storage_temporary">Temporary files</string>
<string name="storage_title">Storage</string> <string name="storage_title">Storage</string>

View File

@@ -85,6 +85,7 @@ fun App(
dependencies.environment, dependencies.environment,
dependencies.deviceInfoProvider, dependencies.deviceInfoProvider,
dependencies.fileSystemService, dependencies.fileSystemService,
graph.coreRepository,
graph.preferencesRepository, graph.preferencesRepository,
dependencies.localNotificationService, dependencies.localNotificationService,
graph.messages, graph.messages,

View File

@@ -3,6 +3,7 @@ package com.vnidrop.app.core
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import uniffi.vnidrop.ReceiveOutputSink import uniffi.vnidrop.ReceiveOutputSink
import uniffi.vnidrop.ReceiveOutputSinkV2
data class CoreStatus( data class CoreStatus(
val endpointId: String, val endpointId: String,
@@ -113,6 +114,23 @@ data class CoreState(
val lastInspection: TicketInspectionModel? = null, 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 { sealed interface CoreSignal {
data class ApprovalChanged(val transferId: ULong) : CoreSignal data class ApprovalChanged(val transferId: ULong) : CoreSignal
data class ReceiverHistoryChanged(val transferId: ULong) : CoreSignal data class ReceiverHistoryChanged(val transferId: ULong) : CoreSignal
@@ -144,6 +162,9 @@ interface CoreGateway {
suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel> suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel>
suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit> suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit>
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit> suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit>
suspend fun receiveWithOutputSinkV2(ticket: String, outputSink: ReceiveOutputSinkV2, receiverName: String): Result<Unit>
suspend fun storageUsage(): Result<CoreStorageUsageModel>
suspend fun receivedArtifacts(): Result<List<ReceivedArtifactModel>>
suspend fun cancel(transferId: ULong): Result<Unit> suspend fun cancel(transferId: ULong): Result<Unit>
suspend fun delete(transferId: ULong): Result<Unit> suspend fun delete(transferId: ULong): Result<Unit>
suspend fun clearReceiveHistory(): Result<ULong> suspend fun clearReceiveHistory(): Result<ULong>

View File

@@ -17,6 +17,7 @@ import kotlin.random.Random
import uniffi.vnidrop.CoreEvent import uniffi.vnidrop.CoreEvent
import uniffi.vnidrop.CoreEventSink import uniffi.vnidrop.CoreEventSink
import uniffi.vnidrop.ReceiveOutputSink import uniffi.vnidrop.ReceiveOutputSink
import uniffi.vnidrop.ReceiveOutputSinkV2
import uniffi.vnidrop.ReceiverRequest import uniffi.vnidrop.ReceiverRequest
import uniffi.vnidrop.ShareMetadataInput import uniffi.vnidrop.ShareMetadataInput
import uniffi.vnidrop.ShareResult import uniffi.vnidrop.ShareResult
@@ -133,6 +134,37 @@ class CoreRepository(
refreshSnapshot() refreshSnapshot()
} }
override suspend fun receiveWithOutputSinkV2(
ticket: String,
outputSink: ReceiveOutputSinkV2,
receiverName: String,
): Result<Unit> = runCore {
requireCore().receiveWithOutputSinkV2(ticket, outputSink, receiverName.ifBlank { null })
refreshSnapshot()
}
override suspend fun storageUsage(): Result<CoreStorageUsageModel> = 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<List<ReceivedArtifactModel>> = 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<Unit> = runCore { override suspend fun cancel(transferId: ULong): Result<Unit> = runCore {
requireCore().cancelTransfer(transferId) requireCore().cancelTransfer(transferId)
refreshSnapshot() refreshSnapshot()

View File

@@ -1,7 +1,7 @@
package com.vnidrop.app.core package com.vnidrop.app.core
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import uniffi.vnidrop.ReceiveOutputSink import uniffi.vnidrop.ReceiveOutputSinkV2
enum class ReceiveFolderKind { enum class ReceiveFolderKind {
FileSystemPath, FileSystemPath,
@@ -19,6 +19,13 @@ data class ReceiveFolder(
val displayName: String, val displayName: String,
) )
data class ReceivedStorageInspection(
val existingBytes: ULong,
val existingCount: Int,
val missingCount: Int,
val inaccessibleCount: Int,
)
enum class FolderAccessStatus { enum class FolderAccessStatus {
Writable, Writable,
PermissionRequired, PermissionRequired,
@@ -32,7 +39,9 @@ interface FileSystemService {
fun effectiveReceiveFolder(configuredFolder: ReceiveFolder): ReceiveFolder = fun effectiveReceiveFolder(configuredFolder: ReceiveFolder): ReceiveFolder =
if (supportsCustomReceiveFolders) configuredFolder else defaultReceiveFolder() if (supportsCustomReceiveFolders) configuredFolder else defaultReceiveFolder()
suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus
fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? suspend fun inspectReceivedArtifacts(artifacts: List<ReceivedArtifactModel>): ReceivedStorageInspection
suspend fun temporaryUsage(): ULong
fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2?
fun canRevealReceiveFolder(folder: ReceiveFolder): Boolean = false fun canRevealReceiveFolder(folder: ReceiveFolder): Boolean = false
suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> = suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> =
Result.failure(UnsupportedOperationException("Revealing the receive folder is not supported")) Result.failure(UnsupportedOperationException("Revealing the receive folder is not supported"))

View File

@@ -162,7 +162,7 @@ class ReceiveViewModel(
} }
val outputSink = fileSystemService.createReceiveOutputSink(folder) val outputSink = fileSystemService.createReceiveOutputSink(folder)
val result = if (outputSink != null) { val result = if (outputSink != null) {
repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName) repository.receiveWithOutputSinkV2(current.ticket, outputSink, current.receiverName)
} else { } else {
repository.receive(current.ticket, folder.value, current.receiverName) repository.receive(current.ticket, folder.value, current.receiverName)
} }

View File

@@ -20,6 +20,7 @@ import vnidrop.shared.generated.resources.notifications_title
import vnidrop.shared.generated.resources.preferences_title import vnidrop.shared.generated.resources.preferences_title
import vnidrop.shared.generated.resources.settings_subtitle import vnidrop.shared.generated.resources.settings_subtitle
import vnidrop.shared.generated.resources.settings_title import vnidrop.shared.generated.resources.settings_title
import vnidrop.shared.generated.resources.storage_title
@Composable @Composable
internal fun SettingsOverview( internal fun SettingsOverview(
@@ -41,6 +42,13 @@ internal fun SettingsOverview(
) )
} }
SettingsGroup { SettingsGroup {
SettingsRow(
icon = SettingsIcons.Drive,
title = stringResource(Res.string.storage_title),
selected = state.selectedSection == SettingsSection.Storage,
onClick = { onSectionSelected(SettingsSection.Storage) },
)
SettingsDivider()
SettingsRow( SettingsRow(
icon = SettingsIcons.Device, icon = SettingsIcons.Device,
title = stringResource(Res.string.preferences_title), title = stringResource(Res.string.preferences_title),

View File

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

View File

@@ -29,6 +29,7 @@ fun SettingsScreen(
onBugContactChanged: (String) -> Unit, onBugContactChanged: (String) -> Unit,
onBugIncludeLogsChanged: (Boolean) -> Unit, onBugIncludeLogsChanged: (Boolean) -> Unit,
onSubmitBugReport: () -> Unit, onSubmitBugReport: () -> Unit,
onDeleteAllTransfers: () -> Unit = {},
) { ) {
if (windowClass == WindowClass.Desktop) { if (windowClass == WindowClass.Desktop) {
Row( Row(
@@ -58,6 +59,7 @@ fun SettingsScreen(
onBugContactChanged = onBugContactChanged, onBugContactChanged = onBugContactChanged,
onBugIncludeLogsChanged = onBugIncludeLogsChanged, onBugIncludeLogsChanged = onBugIncludeLogsChanged,
onSubmitBugReport = onSubmitBugReport, onSubmitBugReport = onSubmitBugReport,
onDeleteAllTransfers = onDeleteAllTransfers,
) )
} }
} }
@@ -91,6 +93,7 @@ fun SettingsScreen(
onBugContactChanged = onBugContactChanged, onBugContactChanged = onBugContactChanged,
onBugIncludeLogsChanged = onBugIncludeLogsChanged, onBugIncludeLogsChanged = onBugIncludeLogsChanged,
onSubmitBugReport = onSubmitBugReport, onSubmitBugReport = onSubmitBugReport,
onDeleteAllTransfers = onDeleteAllTransfers,
) )
} }
} }
@@ -116,12 +119,14 @@ private fun SettingsSectionContent(
onBugContactChanged: (String) -> Unit, onBugContactChanged: (String) -> Unit,
onBugIncludeLogsChanged: (Boolean) -> Unit, onBugIncludeLogsChanged: (Boolean) -> Unit,
onSubmitBugReport: () -> Unit, onSubmitBugReport: () -> Unit,
onDeleteAllTransfers: () -> Unit,
) { ) {
when (section) { when (section) {
SettingsSection.Overview -> Unit SettingsSection.Overview -> Unit
SettingsSection.Preferences -> PreferencesSettings(state, onUsernameChanged, onChooseFolder, onResetFolder, onBack, showBack) SettingsSection.Preferences -> PreferencesSettings(state, onUsernameChanged, onChooseFolder, onResetFolder, onBack, showBack)
SettingsSection.Appearance -> AppearanceSettings(state.themeMode, onThemeModeChanged, onBack, showBack) SettingsSection.Appearance -> AppearanceSettings(state.themeMode, onThemeModeChanged, onBack, showBack)
SettingsSection.Notifications -> NotificationSettings(state, onNotificationsChanged, onOpenNotificationSettings, onBack, showBack) SettingsSection.Notifications -> NotificationSettings(state, onNotificationsChanged, onOpenNotificationSettings, onBack, showBack)
SettingsSection.Storage -> StorageSettings(state, onDeleteAllTransfers, onBack, showBack)
SettingsSection.About -> AboutSettings( SettingsSection.About -> AboutSettings(
state = state, state = state,
onDiagnosticsChanged = onDiagnosticsChanged, onDiagnosticsChanged = onDiagnosticsChanged,

View File

@@ -6,6 +6,7 @@ import com.vnidrop.app.DeviceInfo
import com.vnidrop.app.DeviceInfoProvider import com.vnidrop.app.DeviceInfoProvider
import com.vnidrop.app.PlatformEnvironment import com.vnidrop.app.PlatformEnvironment
import com.vnidrop.app.core.FileSystemService import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.FolderAccessStatus import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.diagnostics.BugReportDraft import com.vnidrop.app.diagnostics.BugReportDraft
@@ -48,10 +49,23 @@ enum class SettingsSection {
Preferences, Preferences,
Appearance, Appearance,
Notifications, Notifications,
Storage,
About, About,
BugReport, 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( data class SettingsState(
val selectedSection: SettingsSection = SettingsSection.Overview, val selectedSection: SettingsSection = SettingsSection.Overview,
val username: String = "", val username: String = "",
@@ -73,6 +87,9 @@ data class SettingsState(
val bugIncludeLogs: Boolean = true, val bugIncludeLogs: Boolean = true,
val isSubmittingBugReport: Boolean = false, val isSubmittingBugReport: Boolean = false,
val bugLogPreviewBytes: Int = 0, val bugLogPreviewBytes: Int = 0,
val storage: StorageBreakdown? = null,
val isCalculatingStorage: Boolean = false,
val isDeletingTransfers: Boolean = false,
) )
sealed interface SettingsEffect { sealed interface SettingsEffect {
@@ -83,6 +100,7 @@ class SettingsViewModel(
private val environment: PlatformEnvironment, private val environment: PlatformEnvironment,
private val deviceInfoProvider: DeviceInfoProvider, private val deviceInfoProvider: DeviceInfoProvider,
private val fileSystemService: FileSystemService, private val fileSystemService: FileSystemService,
private val repository: CoreGateway,
private val preferencesRepository: PreferencesRepository, private val preferencesRepository: PreferencesRepository,
private val notifications: LocalNotificationService, private val notifications: LocalNotificationService,
private val messages: UiMessageController, private val messages: UiMessageController,
@@ -130,6 +148,7 @@ class SettingsViewModel(
fun selectSection(section: SettingsSection) { fun selectSection(section: SettingsSection) {
_state.update { it.copy(selectedSection = section) } _state.update { it.copy(selectedSection = section) }
when (section) { when (section) {
SettingsSection.Storage -> loadStorageUsage()
SettingsSection.About, SettingsSection.BugReport -> { SettingsSection.About, SettingsSection.BugReport -> {
loadDeviceInfo() loadDeviceInfo()
if (section == SettingsSection.BugReport) refreshBugLogPreview() 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) { fun setUsername(value: String) {
hasLocalUsernameDraft = true hasLocalUsernameDraft = true
_state.update { it.copy(username = value) } _state.update { it.copy(username = value) }

View File

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

View File

@@ -666,6 +666,7 @@ class ViewModelsTest {
environment(), environment(),
{ DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") }, { DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") },
fileSystem, fileSystem,
FakeCoreGateway(),
preferences, preferences,
notifications, notifications,
UiMessageController(), UiMessageController(),

View File

@@ -1,12 +1,15 @@
package com.vnidrop.app.support package com.vnidrop.app.support
import com.vnidrop.app.core.CoreGateway import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreStorageUsageModel
import com.vnidrop.app.core.CoreSignal import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.CoreState import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.FileSystemService import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.FolderAccessStatus import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ReceiveFolder 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.ReceiverRequestModel
import com.vnidrop.app.core.Share import com.vnidrop.app.core.Share
import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.core.ShareAccessPolicy
@@ -27,6 +30,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import uniffi.vnidrop.ReceiveOutputSink import uniffi.vnidrop.ReceiveOutputSink
import uniffi.vnidrop.ReceiveOutputSinkV2
class FakeCoreGateway : CoreGateway { class FakeCoreGateway : CoreGateway {
val mutableState = MutableStateFlow(CoreState()) val mutableState = MutableStateFlow(CoreState())
@@ -135,6 +139,17 @@ class FakeCoreGateway : CoreGateway {
awaitReceiveIfNeeded() awaitReceiveIfNeeded()
return receiveResult return receiveResult
} }
override suspend fun receiveWithOutputSinkV2(ticket: String, outputSink: ReceiveOutputSinkV2, receiverName: String): Result<Unit> {
receiveCount += 1
lastReceiveTicket = ticket
lastReceiveReceiverName = receiverName
awaitReceiveIfNeeded()
return receiveResult
}
override suspend fun storageUsage(): Result<CoreStorageUsageModel> = Result.success(
CoreStorageUsageModel(0UL, 0UL, 0UL, 0UL, 0UL),
)
override suspend fun receivedArtifacts(): Result<List<ReceivedArtifactModel>> = Result.success(emptyList())
override suspend fun cancel(transferId: ULong): Result<Unit> { override suspend fun cancel(transferId: ULong): Result<Unit> {
cancelledTransfers += transferId cancelledTransfers += transferId
return Result.success(Unit) return Result.success(Unit)
@@ -227,7 +242,10 @@ class FakeFileSystemService(
override fun effectiveReceiveFolder(configuredFolder: ReceiveFolder) = override fun effectiveReceiveFolder(configuredFolder: ReceiveFolder) =
effectiveFolder ?: super.effectiveReceiveFolder(configuredFolder) effectiveFolder ?: super.effectiveReceiveFolder(configuredFolder)
override suspend fun validateReceiveFolder(folder: ReceiveFolder) = FolderAccessStatus.Writable override suspend fun validateReceiveFolder(folder: ReceiveFolder) = FolderAccessStatus.Writable
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null override suspend fun inspectReceivedArtifacts(artifacts: List<ReceivedArtifactModel>) =
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 fun canRevealReceiveFolder(folder: ReceiveFolder) = canRevealFolder
override suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> { override suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> {
revealedFolders += folder revealedFolders += folder

View File

@@ -2,7 +2,7 @@ package com.vnidrop.app.core
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import uniffi.vnidrop.ReceiveOutputSink import uniffi.vnidrop.ReceiveOutputSinkV2
import java.io.File import java.io.File
@Composable @Composable
@@ -26,7 +26,25 @@ private class JvmFileSystemService : FileSystemService {
}.getOrDefault(FolderAccessStatus.Unavailable) }.getOrDefault(FolderAccessStatus.Unavailable)
} }
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null override suspend fun inspectReceivedArtifacts(artifacts: List<ReceivedArtifactModel>): 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( override suspend fun sharePickedFiles(
repository: CoreGateway, repository: CoreGateway,