saf scanner
This commit is contained in:
@@ -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>>
|
||||
|
||||
|
||||
+54
@@ -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.
|
||||
@@ -66,3 +107,16 @@ class FileRepository @Inject constructor(
|
||||
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,
|
||||
)
|
||||
+15
-2
@@ -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
|
||||
}
|
||||
}
|
||||
+97
-10
@@ -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,35 +100,86 @@ 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,
|
||||
) {
|
||||
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))
|
||||
}
|
||||
|
||||
if (uiState.isScanning) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
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 -> {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
uiState.folders.isEmpty() -> {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_folders_yet),
|
||||
@@ -108,7 +192,9 @@ private fun FolderListContent(
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
@@ -118,6 +204,7 @@ private fun FolderListContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
+21
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user