Merge pull request #25 from sudosylabs/feat/storage-accounting

fix(storage): reclaim transfer cache and track received files
This commit is contained in:
Hammed Abass
2026-07-22 17:33:25 +02:00
committed by GitHub
46 changed files with 1229 additions and 124 deletions

View File

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

View File

@@ -200,9 +200,10 @@
<string name="status_stopped">Beendet</string>
<string name="storage_calculating">Wird berechnet…</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_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_temporary">Temporäre Dateien</string>
<string name="storage_title">Speicher</string>

View File

@@ -200,9 +200,10 @@
<string name="status_stopped">Detenido</string>
<string name="storage_calculating">Calculando…</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_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_temporary">Archivos temporales</string>
<string name="storage_title">Almacenamiento</string>

View File

@@ -200,9 +200,10 @@
<string name="status_stopped">Arrêté</string>
<string name="storage_calculating">Calcul…</string>
<string name="storage_delete_transfers">Supprimer tous les transferts</string>
<string name="storage_delete_transfers_description">Cela efface tous les enregistrements de transferts envoyés et reçus de votre historique. Vos fichiers reçus ne sont pas supprimés. Remarque : le moteur de transfert conserve son contenu stocké, donc les données de transfert peuvent ne pas diminuer. Cette action est irréversible.</string>
<string name="storage_delete_transfers_description">Cela efface de votre historique tous les enregistrements de transferts envoyés et reçus. Vos fichiers reçus ne sont pas supprimés. Le contenu partagé mis en cache qui nest plus cessaire est récupéré automatiquement, ce qui peut prendre un peu de temps. Cette action est irréversible.</string>
<string name="storage_app_data">Données de lapp</string>
<string name="storage_deleting">Suppression…</string>
<string name="storage_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_temporary">Fichiers temporaires</string>
<string name="storage_title">Stockage</string>

View File

@@ -200,9 +200,10 @@
<string name="status_stopped">Interrotto</string>
<string name="storage_calculating">Calcolo…</string>
<string name="storage_delete_transfers">Elimina tutti i trasferimenti</string>
<string name="storage_delete_transfers_description">Questo cancella dalla cronologia tutti i record dei trasferimenti inviati e ricevuti. I suoi file ricevuti non vengono eliminati. Nota: il motore di trasferimento conserva il contenuto memorizzato, quindi i dati di trasferimento potrebbero non diminuire. Questa operazione non può essere annullata.</string>
<string name="storage_delete_transfers_description">Questo cancella dalla cronologia tutti i record dei trasferimenti inviati e ricevuti. I file ricevuti non vengono eliminati. Il contenuto condiviso nella cache che non serve più viene recuperato automaticamente, operazione che può richiedere un po di tempo. Questa azione non può essere annullata.</string>
<string name="storage_app_data">Dati dellapp</string>
<string name="storage_deleting">Eliminazione…</string>
<string name="storage_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_temporary">File temporanei</string>
<string name="storage_title">Archiviazione</string>

View File

@@ -200,9 +200,10 @@
<string name="status_stopped">Gestopt</string>
<string name="storage_calculating">Berekenen…</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_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_temporary">Tijdelijke bestanden</string>
<string name="storage_title">Opslag</string>

View File

@@ -200,9 +200,10 @@
<string name="status_stopped">Zatrzymany</string>
<string name="storage_calculating">Obliczanie…</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_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_temporary">Pliki tymczasowe</string>
<string name="storage_title">Pamięć</string>

View File

@@ -200,9 +200,10 @@
<string name="status_stopped">Parada</string>
<string name="storage_calculating">A calcular…</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_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_temporary">Ficheiros temporários</string>
<string name="storage_title">Armazenamento</string>

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package com.vnidrop.app.core
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import uniffi.vnidrop.ReceiveOutputSink
import uniffi.vnidrop.ReceiveOutputSinkV2
data class CoreStatus(
val endpointId: String,
@@ -113,6 +114,23 @@ data class CoreState(
val lastInspection: TicketInspectionModel? = null,
)
data class CoreStorageUsageModel(
val blobStoreBytes: ULong,
val databaseBytes: ULong,
val logsBytes: ULong,
val previewsBytes: ULong,
val otherCoreBytes: ULong,
) {
val appDataBytes: ULong get() = databaseBytes + logsBytes + previewsBytes + otherCoreBytes
}
data class ReceivedArtifactModel(
val id: String,
val locator: String,
val locatorKind: uniffi.vnidrop.ReceivedLocatorKind,
val logicalSize: ULong,
)
sealed interface CoreSignal {
data class ApprovalChanged(val transferId: ULong) : CoreSignal
data class ReceiverHistoryChanged(val transferId: ULong) : CoreSignal
@@ -144,6 +162,9 @@ interface CoreGateway {
suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel>
suspend fun receive(ticket: String, outputDir: String, 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 delete(transferId: ULong): Result<Unit>
suspend fun clearReceiveHistory(): Result<ULong>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import com.vnidrop.app.DeviceInfo
import com.vnidrop.app.DeviceInfoProvider
import com.vnidrop.app.PlatformEnvironment
import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.diagnostics.BugReportDraft
@@ -48,10 +49,23 @@ enum class SettingsSection {
Preferences,
Appearance,
Notifications,
Storage,
About,
BugReport,
}
data class StorageBreakdown(
val transferCacheBytes: ULong,
val appDataBytes: ULong,
val temporaryBytes: ULong,
val receivedBytes: ULong,
val receivedFileCount: Int,
val missingReceivedFileCount: Int,
val inaccessibleReceivedFileCount: Int,
) {
val deviceImpactBytes: ULong get() = transferCacheBytes + appDataBytes + temporaryBytes + receivedBytes
}
data class SettingsState(
val selectedSection: SettingsSection = SettingsSection.Overview,
val username: String = "",
@@ -73,6 +87,9 @@ data class SettingsState(
val bugIncludeLogs: Boolean = true,
val isSubmittingBugReport: Boolean = false,
val bugLogPreviewBytes: Int = 0,
val storage: StorageBreakdown? = null,
val isCalculatingStorage: Boolean = false,
val isDeletingTransfers: Boolean = false,
)
sealed interface SettingsEffect {
@@ -83,6 +100,7 @@ class SettingsViewModel(
private val environment: PlatformEnvironment,
private val deviceInfoProvider: DeviceInfoProvider,
private val fileSystemService: FileSystemService,
private val repository: CoreGateway,
private val preferencesRepository: PreferencesRepository,
private val notifications: LocalNotificationService,
private val messages: UiMessageController,
@@ -130,6 +148,7 @@ class SettingsViewModel(
fun selectSection(section: SettingsSection) {
_state.update { it.copy(selectedSection = section) }
when (section) {
SettingsSection.Storage -> loadStorageUsage()
SettingsSection.About, SettingsSection.BugReport -> {
loadDeviceInfo()
if (section == SettingsSection.BugReport) refreshBugLogPreview()
@@ -138,6 +157,53 @@ class SettingsViewModel(
}
}
fun loadStorageUsage() {
if (_state.value.isCalculatingStorage) return
viewModelScope.launch {
_state.update { it.copy(isCalculatingStorage = true) }
try {
val coreUsage = repository.storageUsage().getOrThrow()
val artifacts = repository.receivedArtifacts().getOrThrow()
val received = fileSystemService.inspectReceivedArtifacts(artifacts)
_state.update {
it.copy(
storage = StorageBreakdown(
transferCacheBytes = coreUsage.blobStoreBytes,
appDataBytes = coreUsage.appDataBytes,
temporaryBytes = fileSystemService.temporaryUsage(),
receivedBytes = received.existingBytes,
receivedFileCount = received.existingCount,
missingReceivedFileCount = received.missingCount,
inaccessibleReceivedFileCount = received.inaccessibleCount,
),
isCalculatingStorage = false,
)
}
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
_state.update { it.copy(isCalculatingStorage = false) }
messages.error(error)
}
}
}
fun deleteAllTransfers() {
if (_state.value.isDeletingTransfers) return
viewModelScope.launch {
_state.update { it.copy(isDeletingTransfers = true) }
val failures = repository.state.value.transfers
.map { repository.delete(it.transferId) }
.count { it.isFailure }
_state.update { it.copy(isDeletingTransfers = false) }
if (failures == 0) {
loadStorageUsage()
} else {
messages.error(IllegalStateException("Could not delete $failures transfer records"))
}
}
}
fun setUsername(value: String) {
hasLocalUsernameDraft = true
_state.update { it.copy(username = value) }

View File

@@ -0,0 +1,80 @@
package com.vnidrop.app.feature.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.storage_app_data
import vnidrop.shared.generated.resources.storage_calculating
import vnidrop.shared.generated.resources.storage_delete_transfers
import vnidrop.shared.generated.resources.storage_deleting
import vnidrop.shared.generated.resources.storage_total
import vnidrop.shared.generated.resources.storage_received_files
import vnidrop.shared.generated.resources.storage_temporary
import vnidrop.shared.generated.resources.storage_title
import vnidrop.shared.generated.resources.storage_transfer_data
import vnidrop.shared.generated.resources.storage_footer
@Composable
internal fun StorageSettings(
state: SettingsState,
onDeleteAllTransfers: () -> Unit,
onBack: () -> Unit,
showBack: Boolean,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SettingsTopBar(stringResource(Res.string.storage_title), onBack, showBack)
val storage = state.storage
if (storage == null || state.isCalculatingStorage) {
SettingsGroup {
SettingsRow(
icon = SettingsIcons.Drive,
title = stringResource(Res.string.storage_calculating),
trailing = { CircularProgressIndicator() },
)
}
} else {
SettingsGroup {
StorageRow(stringResource(Res.string.storage_transfer_data), storage.transferCacheBytes)
SettingsDivider()
StorageRow(stringResource(Res.string.storage_app_data), storage.appDataBytes)
SettingsDivider()
StorageRow(stringResource(Res.string.storage_temporary), storage.temporaryBytes)
SettingsDivider()
SettingsRow(
icon = SettingsIcons.Folder,
title = stringResource(Res.string.storage_received_files),
value = formatBytes(storage.receivedBytes),
)
SettingsDivider()
StorageRow(stringResource(Res.string.storage_total), storage.deviceImpactBytes)
}
}
Button(
onClick = onDeleteAllTransfers,
enabled = !state.isDeletingTransfers,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
) {
Text(stringResource(if (state.isDeletingTransfers) Res.string.storage_deleting else Res.string.storage_delete_transfers))
}
Text(
stringResource(Res.string.storage_footer),
style = MaterialTheme.typography.bodySmall,
color = LocalVniDropColors.current.foregroundLighter,
)
}
}
@Composable
private fun StorageRow(title: String, bytes: ULong) {
SettingsRow(icon = SettingsIcons.Drive, title = title, value = formatBytes(bytes))
}

View File

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

View File

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

View File

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