saf scanner

This commit is contained in:
m
2026-09-11 16:42:29 +02:00
parent 93c9719969
commit 28586931d3
11 changed files with 600 additions and 35 deletions
@@ -9,6 +9,9 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface FileDao {
@Query("SELECT * FROM files ORDER BY name ASC")
suspend fun getAll(): List<FileEntity>
@Query("SELECT * FROM files WHERE folder_resource_id = :folderResourceId AND \"exists\" = 1 ORDER BY name ASC")
fun observeByFolder(folderResourceId: String): Flow<List<FileEntity>>
@@ -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)
}
@@ -14,6 +14,9 @@ interface FolderDao {
@Query("SELECT * FROM folders ORDER BY name ASC")
fun observeAll(): Flow<List<FolderEntity>>
@Query("SELECT * FROM folders ORDER BY name ASC")
suspend fun getAll(): List<FolderEntity>
@Query("SELECT * FROM folders WHERE parent_resource_id IS NULL ORDER BY name ASC")
fun observeRootFolders(): Flow<List<FolderEntity>>
@@ -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<FileEntity> = 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
}
}
}
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,
)
@@ -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<List<FolderEntity>> = folderDao.observeRootFolders()
@@ -29,6 +31,8 @@ class FolderRepository @Inject constructor(
suspend fun getRootFolders(): List<FolderEntity> = folderDao.getRootFolders()
suspend fun getAll(): List<FolderEntity> = 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(
@@ -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<SyncResult> =
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<String>()
val resourceIdByUri = HashMap<String, String>()
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)
}
@@ -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<FileNode>,
)
/**
* 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<FolderNode> = 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<FolderNode>()
val stack = ArrayDeque<ScanFolder>()
val visited = HashSet<String>() // 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<FileNode> = mutableListOf(),
)
private class ChildDir(val docId: String, val uri: String, val name: String)
private class DirContents(val folders: List<ChildDir>, val files: List<FileNode>)
/** 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<ChildDir>()
val files = ArrayList<FileNode>()
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
}
}
@@ -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
}
}
@@ -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) })
}
}
}
}
@@ -5,5 +5,7 @@ import com.vaultdrop.mobile.data.local.entity.FolderEntity
data class FolderListUiState(
val folders: List<FolderEntity> = 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,
)
@@ -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) }
}
}
}
@@ -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()