mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
Merge pull request #25 from sudosylabs/feat/storage-accounting
fix(storage): reclaim transfer cache and track received files
This commit is contained in:
@@ -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))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -12009,6 +12009,66 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"storage_app_data": {
|
||||||
|
"comment": "Settings > Storage: label for non-transfer application data.",
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "App-Daten"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "App data"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "Datos de la aplicación"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "Données de l’app"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "Dati dell’app"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nl": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "Appgegevens"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pl": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "Dane aplikacji"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "Dados da aplicação"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "Данные приложения"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"storage_calculating": {
|
"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 can’t be undone."
|
"value": "This clears all sent and received transfer records from your history. Your received files are not deleted. Cached shared content that is no longer needed is reclaimed automatically, which may take a little time. This can’t be undone."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es": {
|
"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 n’est plus né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 you’ve shared. The button below clears your transfer records; the engine keeps its stored content, so this value may not drop. Received files are your downloaded files and are never deleted here."
|
"value": "Transfer data includes your history and cached content for active shares. Unneeded cache is reclaimed automatically after transfer records are removed, which may take a little time. Received files are tracked for this summary but are never deleted here."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es": {
|
"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": "Данные передачи включают историю и кэшированное содержимое активных раздач. Ненужный кэш освобождается автоматически после удаления записей; это может занять некоторое время. Полученные файлы учитываются в этой сводке, но никогда не удаляются здесь."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
69
crates/vnidrop/src/runtime/storage.rs
Normal file
69
crates/vnidrop/src/runtime/storage.rs
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 can’t 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 can’t be undone.",
|
||||||
"fr": "Cela efface tous les enregistrements de transferts envoyés et reçus de votre historique. Vos fichiers reçus ne sont pas supprimés. Remarque : le moteur de transfert conserve son contenu stocké, donc les données de transfert peuvent ne pas diminuer. Cette action est irréversible.",
|
"fr": "Cela efface de votre historique tous les enregistrements de transferts envoyés et reçus. Vos fichiers reçus ne sont pas supprimés. Le contenu partagé mis en cache qui n’est plus nécessaire est récupéré automatiquement, ce qui peut prendre un peu de temps. Cette action est irréversible.",
|
||||||
"es": "Esto borra 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 l’app",
|
||||||
|
"es": "Datos de la aplicación",
|
||||||
|
"it": "Dati dell’app",
|
||||||
|
"de": "App-Daten",
|
||||||
|
"pt": "Dados da aplicação",
|
||||||
|
"pl": "Dane aplikacji",
|
||||||
|
"nl": "Appgegevens",
|
||||||
|
"ru": "Данные приложения"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage_deleting": {
|
"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 you’ve shared. The button below clears your transfer records; the engine keeps its stored content, so this value may not drop. Received files are your downloaded files and are never deleted here.",
|
"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": {
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 n’est plus nécessaire est récupéré automatiquement, ce qui peut prendre un peu de temps. Cette action est irréversible.</string>
|
||||||
|
<string name="storage_app_data">Données de l’app</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>
|
||||||
|
|||||||
@@ -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 dell’app</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 can’t 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 can’t 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 you’ve shared. The button below clears your transfer records; the engine keeps its stored content, so this value may not drop. Received files are your downloaded files and are never deleted here.</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>
|
||||||
|
|||||||
@@ -88,6 +88,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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import vnidrop.shared.generated.resources.appearance_title
|
|||||||
import vnidrop.shared.generated.resources.notifications_title
|
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_title
|
import vnidrop.shared.generated.resources.settings_title
|
||||||
|
import vnidrop.shared.generated.resources.storage_title
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SettingsOverview(
|
internal fun SettingsOverview(
|
||||||
@@ -32,6 +33,13 @@ internal fun SettingsOverview(
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
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),
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user