From 28586931d3d633f00a7f1b04d895f472fa6f96c0 Mon Sep 17 00:00:00 2001 From: m Date: Fri, 11 Sep 2026 16:42:29 +0200 Subject: [PATCH] saf scanner --- .../mobile/data/local/dao/FileDao.kt | 6 + .../mobile/data/local/dao/FolderDao.kt | 3 + .../mobile/data/repository/FileRepository.kt | 56 +++++- .../data/repository/FolderRepository.kt | 17 +- .../mobile/features/sync/DeviceSync.kt | 140 ++++++++++++++ .../mobile/features/sync/SafScanner.kt | 176 ++++++++++++++++++ .../mobile/features/sync/SyncViewModel.kt | 55 ++++++ .../mobile/ui/folderlist/FolderListScreen.kt | 151 +++++++++++---- .../mobile/ui/folderlist/FolderListUiState.kt | 2 + .../ui/folderlist/FolderListViewModel.kt | 21 +++ .../mobile/ui/navigation/VaultDropApp.kt | 8 + 11 files changed, 600 insertions(+), 35 deletions(-) create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/DeviceSync.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SafScanner.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt index 4a94fb7..1caf6dc 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt @@ -9,6 +9,9 @@ import kotlinx.coroutines.flow.Flow @Dao interface FileDao { + @Query("SELECT * FROM files ORDER BY name ASC") + suspend fun getAll(): List + @Query("SELECT * FROM files WHERE folder_resource_id = :folderResourceId AND \"exists\" = 1 ORDER BY name ASC") fun observeByFolder(folderResourceId: String): Flow> @@ -21,6 +24,9 @@ interface FileDao { @Upsert suspend fun upsert(file: FileEntity) + @Query("UPDATE files SET \"exists\" = 0, updated_at = :updatedAt WHERE resource_id = :resourceId") + suspend fun markMissing(resourceId: String, updatedAt: Long) + @Query("DELETE FROM files WHERE resource_id = :resourceId") suspend fun remove(resourceId: String) } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt index 5daff67..fdb4a4e 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt @@ -14,6 +14,9 @@ interface FolderDao { @Query("SELECT * FROM folders ORDER BY name ASC") fun observeAll(): Flow> + @Query("SELECT * FROM folders ORDER BY name ASC") + suspend fun getAll(): List + @Query("SELECT * FROM folders WHERE parent_resource_id IS NULL ORDER BY name ASC") fun observeRootFolders(): Flow> diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt index d403cae..92319e5 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt @@ -5,6 +5,7 @@ import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.data.local.entity.FileStatus import com.vaultdrop.mobile.data.remote.ApiClient import com.vaultdrop.mobile.data.remote.dto.FileDto +import com.vaultdrop.mobile.domain.GenerateId import kotlinx.coroutines.flow.Flow import javax.inject.Inject import javax.inject.Singleton @@ -20,6 +21,7 @@ import javax.inject.Singleton class FileRepository @Inject constructor( private val fileDao: FileDao, private val apiClient: ApiClient, + private val generateId: GenerateId, ) { /** Fichiers visibles du dossier, locaux + cloud — source de l'UI. */ @@ -29,6 +31,45 @@ class FileRepository @Inject constructor( suspend fun getFile(resourceId: String): FileEntity? = fileDao.getByResourceId(resourceId) + suspend fun getAll(): List = fileDao.getAll() + + /** + * Upsert local d'un fichier SAF — miroir de `saveFile()` JS : déduplication + * par `resource_id` ou `uri`, conservation de l'identité/sync_status/owner. + */ + suspend fun saveLocalFile( + input: SaveFileInput, + folderResourceId: String, + ownerId: String? = null, + ): FileEntity { + val now = System.currentTimeMillis() + val existing = input.resourceId?.let { fileDao.getByResourceId(it) } + ?: fileDao.getByUri(input.uri) + + val entity = FileEntity( + id = existing?.id ?: 0L, + resourceId = existing?.resourceId ?: input.resourceId ?: generateId.newResourceId(), + uri = input.uri, + name = input.name, + folderResourceId = folderResourceId, + extension = input.extension ?: existing?.extension, + size = input.size, + mimeType = input.mimeType, + exists = if (input.exists) 1 else 0, + lastModified = input.lastModified, + ownerId = ownerId ?: existing?.ownerId, + syncStatus = existing?.syncStatus ?: input.syncStatus ?: FileStatus.LOCAL, + addedAt = existing?.addedAt ?: now, + updatedAt = now, + ) + fileDao.upsert(entity) + return entity + } + + /** Marque un fichier disparu de l'arborescence (`exists = 0`) — jamais supprimé. */ + suspend fun markMissing(resourceId: String, updatedAt: Long) = + fileDao.markMissing(resourceId, updatedAt) + /** * `GET /files?folderId=...` (1re page, tri serveur) puis upsert cloud de * chaque fichier. Ne supprime jamais de lignes locales. @@ -65,4 +106,17 @@ class FileRepository @Inject constructor( companion object { private const val PAGE_SIZE = 50 } -} \ No newline at end of file +} + +data class SaveFileInput( + val uri: String, + val name: String, + val extension: String? = null, + val size: Long, + val mimeType: String? = null, + val lastModified: Long? = null, + val exists: Boolean = true, + val resourceId: String? = null, + /** Fallback de sync_status pour une nouvelle ligne (défaut local). */ + val syncStatus: String? = null, +) \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt index 635f7f5..bed9617 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt @@ -5,6 +5,7 @@ import com.vaultdrop.mobile.data.local.entity.FolderEntity import com.vaultdrop.mobile.data.local.entity.FolderStatus import com.vaultdrop.mobile.data.remote.ApiClient import com.vaultdrop.mobile.data.remote.dto.FolderDto +import com.vaultdrop.mobile.domain.DeviceIdentity import com.vaultdrop.mobile.domain.GenerateId import kotlinx.coroutines.flow.Flow import javax.inject.Inject @@ -19,6 +20,7 @@ class FolderRepository @Inject constructor( private val folderDao: FolderDao, private val apiClient: ApiClient, private val generateId: GenerateId, + private val deviceIdentity: DeviceIdentity, ) { fun observeRootFolders(): Flow> = folderDao.observeRootFolders() @@ -29,6 +31,8 @@ class FolderRepository @Inject constructor( suspend fun getRootFolders(): List = folderDao.getRootFolders() + suspend fun getAll(): List = folderDao.getAll() + suspend fun getFolder(resourceId: String): FolderEntity? = folderDao.getByResourceId(resourceId) @@ -63,10 +67,15 @@ class FolderRepository @Inject constructor( /** * Upsert local d'un dossier physique (SAF) — établi par UX volontairement : * ajoute une ligne avec sa `uri` si absente, ou met à jour ses champs. + * + * `parentResourceId` non-null écrase le parent existant (un déplacement dans + * l'arborescence SAF est reflété) ; null préserve le parent courant. `ownerId` + * optionnel évite de relire l'identité device à chaque ligne d'un walk. */ suspend fun saveFolder( input: SaveFolderInput, parentResourceId: String? = null, + ownerId: String? = null, ): FolderEntity { val now = System.currentTimeMillis() val existing = input.resourceId?.let { folderDao.getByResourceId(it) } @@ -78,8 +87,8 @@ class FolderRepository @Inject constructor( uri = input.uri, name = input.name, exists = input.exists?.let { if (it) 1 else 0 }, - parentResourceId = existing?.parentResourceId ?: parentResourceId, - ownerId = existing?.ownerId, + parentResourceId = parentResourceId ?: existing?.parentResourceId, + ownerId = ownerId ?: existing?.ownerId ?: deviceIdentity.getOrCreate(), syncStatus = existing?.syncStatus ?: FolderStatus.LOCAL, addedAt = existing?.addedAt ?: now, updatedAt = now, @@ -87,6 +96,10 @@ class FolderRepository @Inject constructor( folderDao.upsert(entity) return entity } + + /** Marque un dossier disparu de l'arborescence (`exists = 0`) — jamais supprimé. */ + suspend fun markMissing(resourceId: String, updatedAt: Long) = + folderDao.markMissing(resourceId, updatedAt) } data class SaveFolderInput( diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/DeviceSync.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/DeviceSync.kt new file mode 100644 index 0000000..501399a --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/DeviceSync.kt @@ -0,0 +1,140 @@ +package com.vaultdrop.mobile.features.sync + +import android.net.Uri +import androidx.room.withTransaction +import com.vaultdrop.mobile.data.local.AppDatabase +import com.vaultdrop.mobile.data.local.entity.FolderEntity +import com.vaultdrop.mobile.data.repository.FileRepository +import com.vaultdrop.mobile.data.repository.FolderRepository +import com.vaultdrop.mobile.data.repository.SaveFileInput +import com.vaultdrop.mobile.data.repository.SaveFolderInput +import com.vaultdrop.mobile.domain.DeviceIdentity +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Synchronisation device↔SAF, miroir de `features/syncDevice.ts` (Expo). + * + * Deux phases par racine : + * - **Phase 1** — listing SAF via [SafScanner] sur `Dispatchers.IO`, + * HORS transaction : l'UI peut lire la DB pendant un parcours long ; + * - **Phase 2** — upserts Room dans une seule transaction courte (SQL pur), + * atomique : une interruption pendant le walk ne laisse aucun état partiel. + * + * Réconciliation : les lignes sous la racine avec une `uri` non revue sont + * marquées `exists = 0` (jamais supprimées). Single-flight via [mutex] — + * deux walks concurrents ne peuvent pas entrelacer leurs upserts. + */ +@Singleton +class DeviceSync @Inject constructor( + private val appDatabase: AppDatabase, + private val scanner: SafScanner, + private val folderRepository: FolderRepository, + private val fileRepository: FileRepository, + private val deviceIdentity: DeviceIdentity, +) { + + data class SyncResult( + val rootUri: String, + val folders: Int, + val files: Int, + val missing: Int, + ) + + private val mutex = Mutex() + + /** Marche d'une racine précise — à lancer après un `pickDirectory`. */ + suspend fun syncRoot(rootResourceId: String): SyncResult = + mutex.withLock { + val root = folderRepository.getFolder(rootResourceId) + ?: error("unknown root folder: $rootResourceId") + doSyncRoot(root) + } + + /** Marche de toutes les racines (pour un futur rafraîchissement de fond). */ + suspend fun syncAll(): List = + mutex.withLock { + folderRepository.getRootFolders() + .filter { it.uri != null } + .map { doSyncRoot(it) } + } + + private suspend fun doSyncRoot(root: FolderEntity): SyncResult { + val rootUri = checkNotNull(root.uri) { "root '${root.name}' has no physical uri" } + val ownerId = deviceIdentity.getOrCreate() + + // Phase 1 — listing SAF (I/O disque, HORS transaction). + val folders = scanner.scanTree(Uri.parse(rootUri)) + + // Phase 2 — écritures Room (transaction courte, SQL pur). + return appDatabase.withTransaction { + val seen = HashSet() + val resourceIdByUri = HashMap() + var fileCount = 0 + + for (folder in folders) { + seen += folder.uri + val parentResourceId = + if (folder.parentUri == null) null + else resourceIdByUri[folder.parentUri] ?: root.resourceId + val saved = folderRepository.saveFolder( + SaveFolderInput(uri = folder.uri, name = folder.name, exists = true), + parentResourceId = parentResourceId, + ownerId = ownerId, + ) + resourceIdByUri[folder.uri] = saved.resourceId + } + + for (folder in folders) { + val folderResourceId = resourceIdByUri[folder.uri] ?: root.resourceId + for (file in folder.files) { + seen += file.uri + fileRepository.saveLocalFile( + input = SaveFileInput( + uri = file.uri, + name = file.name, + extension = file.name.extensionOrNull(), + size = file.size, + mimeType = file.mimeType, + lastModified = file.lastModified, + ), + folderResourceId = folderResourceId, + ownerId = ownerId, + ) + fileCount++ + } + } + + var missing = 0 + val now = System.currentTimeMillis() + for (folder in folderRepository.getAll()) { + val folderUri = folder.uri + if (folder.exists != 0 && folderUri != null && isChildOf(folderUri, rootUri) && folderUri !in seen) { + folderRepository.markMissing(folder.resourceId, now) + missing++ + } + } + for (file in fileRepository.getAll()) { + val fileUri = file.uri + if (file.exists != 0 && fileUri != null && isChildOf(fileUri, rootUri) && fileUri !in seen) { + fileRepository.markMissing(file.resourceId, now) + missing++ + } + } + + SyncResult(rootUri = rootUri, folders = folders.size, files = fileCount, missing = missing) + } + } + + private fun isChildOf(uri: String, rootUri: String): Boolean = + uri == rootUri || uri.startsWith("$rootUri/") +} + +private fun String.extensionOrNull(): String? { + val dot = lastIndexOf('.') + if (dot <= 0 || dot == length - 1) return null + return substring(dot + 1) +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SafScanner.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SafScanner.kt new file mode 100644 index 0000000..190801f --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SafScanner.kt @@ -0,0 +1,176 @@ +package com.vaultdrop.mobile.features.sync + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import javax.inject.Inject +import javax.inject.Singleton + +/** Métadonnée d'un fichier SAF — jamais son contenu. */ +data class FileNode( + val uri: String, + val name: String, + val size: Long, + val mimeType: String?, + val lastModified: Long?, +) + +/** Métadonnée d'un dossier SAF, avec ses fichiers directs. */ +data class FolderNode( + val uri: String, + val name: String, + /** uri du dossier parent (null pour la racine) — calculé au DFS, pas de `dirname()`. */ + val parentUri: String?, + val files: List, +) + +/** + * Parcours récursif SAF, conçu pour ne pas exploser les ressources ni bloquer : + * + * - **itératif** (pile explicite) : aucune récursion JVM, aucune limite de + * profondeur — une arborescence profonde ne fait pas déborder la pile ; + * - **`Dispatchers.IO`** pour toute l'I/O ContentResolver : l'UI ne bloque + * jamais pendant l'exploration ; + * - **coopératif et annulable** : `yield()` tous les `YIELD_EVERY` dossiers et + * `ensureActive()` à chaque étape (si le ViewModel est détruit, on s'arrête) ; + * - **une seule requête ContentResolver par dossier**, métadonnées uniquement + * (aucun chargement de contenu en mémoire). + */ +@Singleton +class SafScanner @Inject constructor( + @ApplicationContext private val context: Context, +) { + + private val resolver: ContentResolver get() = context.contentResolver + + /** Marche la racine `treeUri` ; renvoie tous les dossiers en ordre préfixe (DFS). */ + suspend fun scanTree(treeUri: Uri): List = withContext(Dispatchers.IO) { + val rootDocId = DocumentsContract.getTreeDocumentId(treeUri) + val root = ScanFolder( + uri = treeUri.toString(), + docId = rootDocId, + name = rootName(treeUri, rootDocId), + parentUri = null, + ) + + val all = ArrayList() + val stack = ArrayDeque() + val visited = HashSet() // anti-cycle : un provider pathologique ne doit jamais boucler + var processed = 0 + + stack.addLast(root) + while (stack.isNotEmpty()) { + ensureActive() + if (++processed % YIELD_EVERY == 0) yield() + + val folder = stack.removeLast() + if (!visited.add(folder.docId)) continue + + val contents = listDir(treeUri, folder.docId) + folder.files += contents.files + all += FolderNode(folder.uri, folder.name, folder.parentUri, folder.files) + + // push en ordre inverse : le premier enfant listé est dépilé en premier + // → ordre préfixe (un dossier précède toujours ses descendants). + for (i in contents.folders.indices.reversed()) { + val child = contents.folders[i] + stack.addLast( + ScanFolder( + uri = child.uri, + docId = child.docId, + name = child.name, + parentUri = folder.uri, + ), + ) + } + } + all + } + + private class ScanFolder( + val uri: String, + val docId: String, + val name: String, + val parentUri: String?, + val files: MutableList = mutableListOf(), + ) + + private class ChildDir(val docId: String, val uri: String, val name: String) + private class DirContents(val folders: List, val files: List) + + /** Liste le contenu direct d'un dossier (fils + fichiers), en une requête. */ + private fun listDir(treeUri: Uri, dirDocId: String): DirContents { + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, dirDocId) + val folders = ArrayList() + val files = ArrayList() + + resolver.query( + childrenUri, + arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_SIZE, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + ), + null, + null, + "${DocumentsContract.Document.COLUMN_DISPLAY_NAME} ASC", + )?.use { cursor -> + val iDoc = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + val iName = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + val iMime = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE) + val iSize = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE) + val iLast = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_LAST_MODIFIED) + + while (cursor.moveToNext()) { + val docId = cursor.getString(iDoc) + if (docId == dirDocId) continue // certains providers renvoient le dossier lui-même + val uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId).toString() + val mime = if (iMime >= 0) cursor.getString(iMime) else null + val name = if (iName >= 0) cursor.getString(iName) + else docId.substringAfterLast('/') + + if (mime == DocumentsContract.Document.MIME_TYPE_DIR) { + folders += ChildDir(docId, uri, name) + } else { + files += FileNode( + uri = uri, + name = name, + size = if (iSize >= 0 && !cursor.isNull(iSize)) cursor.getLong(iSize) else 0L, + mimeType = mime, + lastModified = if (iLast >= 0 && !cursor.isNull(iLast)) cursor.getLong(iLast) else null, + ) + } + } + } + return DirContents(folders, files) + } + + /** Nom du dossier racine — DISPLAY_NAME du nœud racine du tree SAF. */ + private fun rootName(treeUri: Uri, rootDocId: String): String { + val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, rootDocId) + return runCatching { + resolver.query( + docUri, + arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst() && !cursor.isNull(0)) cursor.getString(0) else null + } + }.getOrNull() ?: rootDocId.substringAfter(':').substringAfterLast('/') + } + + private companion object { + /** Nombre de dossiers visités entre deux `yield()` (coopération inter-coroutines). */ + const val YIELD_EVERY = 64 + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt new file mode 100644 index 0000000..7c1081d --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt @@ -0,0 +1,55 @@ +package com.vaultdrop.mobile.features.sync + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +/** + * Boucle de fond : explore périodiquement toutes les racines SAF via + * [DeviceSync.syncAll] — miroir de `useSyncDevice(30_000)` côté Expo. + * + * Garanties : + * - l'exploration est sur `Dispatchers.IO` et sérialisée par le Mutex de + * [DeviceSync] : jamais deux walks en parallèle, jamais de blocage UI ; + * - un échec n'interrompt pas la boucle (retry au tick suivant) ; + * - le scope du ViewModel arrête la boucle (cancel) à la destruction du + * store → le walk en cours s'arrête via `ensureActive()` du scanner. + */ +@HiltViewModel +class SyncViewModel @Inject constructor( + private val deviceSync: DeviceSync, +) : ViewModel() { + + private var loopJob: Job? = null + + /** Démarre la boucle une seule fois (idempotent). */ + fun ensureStarted() { + if (loopJob?.isActive == true) return + loopJob = viewModelScope.launch { + // Premier cycle différé : laisser le boot et le premier écran + // répondre avant de lancer un walk potentiellement long. + delay(FIRST_DELAY_MS) + while (isActive) { + runCatching { deviceSync.syncAll() } + .onSuccess { results -> + if (results.isNotEmpty()) Timber.d("syncAll: %s", results) + } + .onFailure { e -> Timber.w(e, "syncAll failed, retrying later") } + delay(INTERVAL_MS) + } + } + } + + companion object { + /** Cadence de la boucle — même valeur que `useSyncDevice(30_000)` Expo. */ + const val INTERVAL_MS = 30_000L + /** Délai avant le premier cycle (au démarrage de l'app). */ + const val FIRST_DELAY_MS = 2_000L + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt index f2cdeda..1af531c 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt @@ -1,24 +1,37 @@ package com.vaultdrop.mobile.ui.folderlist +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.DocumentsContract +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -27,6 +40,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -48,6 +62,25 @@ fun FolderListScreen( viewModel: FolderListViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val folderLabel = stringResource(R.string.folder) + + val pickFolderLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocumentTree(), + ) { uri: Uri? -> + if (uri == null) return@rememberLauncherForActivityResult + // Permissions persistantes : l'app ré-ouvrira le dossier aux prochains lancements. + runCatching { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + } + viewModel.savePickedFolder( + uri = uri.toString(), + name = uri.displayName(context) ?: uri.lastPathSegment ?: folderLabel, + ) + } Scaffold( topBar = { @@ -67,53 +100,107 @@ fun FolderListScreen( FolderListContent( uiState = uiState, onOpenFolder = onOpenFolder, + onAddFolder = { pickFolderLauncher.launch(null) }, modifier = Modifier.padding(padding), ) } } +/** Nom affiché d'un dossier SAF via DocumentsContract (DISPLAY_NAME). */ +private fun Uri.displayName(context: Context): String? = runCatching { + val docId = DocumentsContract.getTreeDocumentId(this) + val docUri = DocumentsContract.buildDocumentUriUsingTree(this, docId) + context.contentResolver.query( + docUri, + arrayOf(OpenableColumns.DISPLAY_NAME), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } +}.getOrNull() + @Composable private fun FolderListContent( uiState: FolderListUiState, onOpenFolder: (String) -> Unit, + onAddFolder: () -> Unit, modifier: Modifier = Modifier, ) { - when { - uiState.folders.isEmpty() && uiState.isRefreshing -> { - Column( - modifier = modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - CircularProgressIndicator() - } + Column(modifier = modifier.fillMaxSize()) { + Button( + onClick = onAddFolder, + enabled = !uiState.isScanning, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Icon(Icons.Filled.Add, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.add_folder)) } - uiState.folders.isEmpty() -> { - Column( - modifier = modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = stringResource(R.string.no_folders_yet), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + if (uiState.isScanning) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) } - else -> { - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(uiState.folders, key = { it.resourceId }) { folder -> - FolderRow(folder, onClick = { onOpenFolder(folder.resourceId) }) + uiState.error?.let { error -> + Text( + text = error, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + + when { + uiState.folders.isEmpty() && uiState.isRefreshing -> { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + uiState.folders.isEmpty() -> { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.no_folders_yet), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + else -> { + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(uiState.folders, key = { it.resourceId }) { folder -> + FolderRow(folder, onClick = { onOpenFolder(folder.resourceId) }) + } } } } diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt index 4bc19f3..e97472f 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt @@ -5,5 +5,7 @@ import com.vaultdrop.mobile.data.local.entity.FolderEntity data class FolderListUiState( val folders: List = emptyList(), val isRefreshing: Boolean = false, + /** true pendant l'exploration SAF d'une racine (marche récursive en cours). */ + val isScanning: Boolean = false, val error: String? = null, ) \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt index 8f77edc..55b49ca 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope import com.vaultdrop.mobile.auth.TokenProvider import com.vaultdrop.mobile.data.remote.ApiException import com.vaultdrop.mobile.data.repository.FolderRepository +import com.vaultdrop.mobile.data.repository.SaveFolderInput +import com.vaultdrop.mobile.features.sync.DeviceSync import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -18,6 +20,7 @@ import javax.inject.Inject class FolderListViewModel @Inject constructor( private val folderRepository: FolderRepository, private val tokenProvider: TokenProvider, + private val deviceSync: DeviceSync, ) : ViewModel() { private val _uiState = MutableStateFlow(FolderListUiState()) @@ -55,4 +58,22 @@ class FolderListViewModel @Inject constructor( _uiState.update { it.copy(isRefreshing = false) } } } + + /** Sélectionne un dossier SAF, le persiste puis explore récursivement. */ + fun savePickedFolder(uri: String, name: String) { + viewModelScope.launch { + _uiState.update { it.copy(isScanning = true, error = null) } + runCatching { + val saved = folderRepository.saveFolder( + SaveFolderInput(uri = uri, name = name, exists = true), + ) + val result = deviceSync.syncRoot(saved.resourceId) + Timber.d("syncRoot %s", result) + }.onFailure { e -> + Timber.w(e, "syncRoot failed") + _uiState.update { it.copy(error = e.message ?: "Erreur lors de l'ajout du dossier") } + } + _uiState.update { it.copy(isScanning = false) } + } + } } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/VaultDropApp.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/VaultDropApp.kt index 9da5ed9..c9b7939 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/VaultDropApp.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/VaultDropApp.kt @@ -5,11 +5,13 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vaultdrop.mobile.features.sync.SyncViewModel import com.vaultdrop.mobile.ui.auth.AuthState import com.vaultdrop.mobile.ui.auth.AuthViewModel import com.vaultdrop.mobile.ui.auth.LoginScreen @@ -32,9 +34,15 @@ private fun SplashScreen() { @Composable fun VaultDropApp( authViewModel: AuthViewModel = hiltViewModel(), + syncViewModel: SyncViewModel = hiltViewModel(), ) { val authState by authViewModel.authState.collectAsStateWithLifecycle() + // Une fois la session résolue, lancer la boucle de fond (idempotente). + LaunchedEffect(authState) { + if (authState != AuthState.Loading) syncViewModel.ensureStarted() + } + when (authState) { AuthState.Loading -> SplashScreen() AuthState.SignedOut -> LoginScreen()