saf scanner
This commit is contained in:
@@ -9,6 +9,9 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
@Dao
|
@Dao
|
||||||
interface FileDao {
|
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")
|
@Query("SELECT * FROM files WHERE folder_resource_id = :folderResourceId AND \"exists\" = 1 ORDER BY name ASC")
|
||||||
fun observeByFolder(folderResourceId: String): Flow<List<FileEntity>>
|
fun observeByFolder(folderResourceId: String): Flow<List<FileEntity>>
|
||||||
|
|
||||||
@@ -21,6 +24,9 @@ interface FileDao {
|
|||||||
@Upsert
|
@Upsert
|
||||||
suspend fun upsert(file: FileEntity)
|
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")
|
@Query("DELETE FROM files WHERE resource_id = :resourceId")
|
||||||
suspend fun remove(resourceId: String)
|
suspend fun remove(resourceId: String)
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,9 @@ interface FolderDao {
|
|||||||
@Query("SELECT * FROM folders ORDER BY name ASC")
|
@Query("SELECT * FROM folders ORDER BY name ASC")
|
||||||
fun observeAll(): Flow<List<FolderEntity>>
|
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")
|
@Query("SELECT * FROM folders WHERE parent_resource_id IS NULL ORDER BY name ASC")
|
||||||
fun observeRootFolders(): Flow<List<FolderEntity>>
|
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.local.entity.FileStatus
|
||||||
import com.vaultdrop.mobile.data.remote.ApiClient
|
import com.vaultdrop.mobile.data.remote.ApiClient
|
||||||
import com.vaultdrop.mobile.data.remote.dto.FileDto
|
import com.vaultdrop.mobile.data.remote.dto.FileDto
|
||||||
|
import com.vaultdrop.mobile.domain.GenerateId
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -20,6 +21,7 @@ import javax.inject.Singleton
|
|||||||
class FileRepository @Inject constructor(
|
class FileRepository @Inject constructor(
|
||||||
private val fileDao: FileDao,
|
private val fileDao: FileDao,
|
||||||
private val apiClient: ApiClient,
|
private val apiClient: ApiClient,
|
||||||
|
private val generateId: GenerateId,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** Fichiers visibles du dossier, locaux + cloud — source de l'UI. */
|
/** Fichiers visibles du dossier, locaux + cloud — source de l'UI. */
|
||||||
@@ -29,6 +31,45 @@ class FileRepository @Inject constructor(
|
|||||||
suspend fun getFile(resourceId: String): FileEntity? =
|
suspend fun getFile(resourceId: String): FileEntity? =
|
||||||
fileDao.getByResourceId(resourceId)
|
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
|
* `GET /files?folderId=...` (1re page, tri serveur) puis upsert cloud de
|
||||||
* chaque fichier. Ne supprime jamais de lignes locales.
|
* chaque fichier. Ne supprime jamais de lignes locales.
|
||||||
@@ -66,3 +107,16 @@ class FileRepository @Inject constructor(
|
|||||||
private const val PAGE_SIZE = 50
|
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.local.entity.FolderStatus
|
||||||
import com.vaultdrop.mobile.data.remote.ApiClient
|
import com.vaultdrop.mobile.data.remote.ApiClient
|
||||||
import com.vaultdrop.mobile.data.remote.dto.FolderDto
|
import com.vaultdrop.mobile.data.remote.dto.FolderDto
|
||||||
|
import com.vaultdrop.mobile.domain.DeviceIdentity
|
||||||
import com.vaultdrop.mobile.domain.GenerateId
|
import com.vaultdrop.mobile.domain.GenerateId
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -19,6 +20,7 @@ class FolderRepository @Inject constructor(
|
|||||||
private val folderDao: FolderDao,
|
private val folderDao: FolderDao,
|
||||||
private val apiClient: ApiClient,
|
private val apiClient: ApiClient,
|
||||||
private val generateId: GenerateId,
|
private val generateId: GenerateId,
|
||||||
|
private val deviceIdentity: DeviceIdentity,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun observeRootFolders(): Flow<List<FolderEntity>> = folderDao.observeRootFolders()
|
fun observeRootFolders(): Flow<List<FolderEntity>> = folderDao.observeRootFolders()
|
||||||
@@ -29,6 +31,8 @@ class FolderRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun getRootFolders(): List<FolderEntity> = folderDao.getRootFolders()
|
suspend fun getRootFolders(): List<FolderEntity> = folderDao.getRootFolders()
|
||||||
|
|
||||||
|
suspend fun getAll(): List<FolderEntity> = folderDao.getAll()
|
||||||
|
|
||||||
suspend fun getFolder(resourceId: String): FolderEntity? =
|
suspend fun getFolder(resourceId: String): FolderEntity? =
|
||||||
folderDao.getByResourceId(resourceId)
|
folderDao.getByResourceId(resourceId)
|
||||||
|
|
||||||
@@ -63,10 +67,15 @@ class FolderRepository @Inject constructor(
|
|||||||
/**
|
/**
|
||||||
* Upsert local d'un dossier physique (SAF) — établi par UX volontairement :
|
* Upsert local d'un dossier physique (SAF) — établi par UX volontairement :
|
||||||
* ajoute une ligne avec sa `uri` si absente, ou met à jour ses champs.
|
* 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(
|
suspend fun saveFolder(
|
||||||
input: SaveFolderInput,
|
input: SaveFolderInput,
|
||||||
parentResourceId: String? = null,
|
parentResourceId: String? = null,
|
||||||
|
ownerId: String? = null,
|
||||||
): FolderEntity {
|
): FolderEntity {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val existing = input.resourceId?.let { folderDao.getByResourceId(it) }
|
val existing = input.resourceId?.let { folderDao.getByResourceId(it) }
|
||||||
@@ -78,8 +87,8 @@ class FolderRepository @Inject constructor(
|
|||||||
uri = input.uri,
|
uri = input.uri,
|
||||||
name = input.name,
|
name = input.name,
|
||||||
exists = input.exists?.let { if (it) 1 else 0 },
|
exists = input.exists?.let { if (it) 1 else 0 },
|
||||||
parentResourceId = existing?.parentResourceId ?: parentResourceId,
|
parentResourceId = parentResourceId ?: existing?.parentResourceId,
|
||||||
ownerId = existing?.ownerId,
|
ownerId = ownerId ?: existing?.ownerId ?: deviceIdentity.getOrCreate(),
|
||||||
syncStatus = existing?.syncStatus ?: FolderStatus.LOCAL,
|
syncStatus = existing?.syncStatus ?: FolderStatus.LOCAL,
|
||||||
addedAt = existing?.addedAt ?: now,
|
addedAt = existing?.addedAt ?: now,
|
||||||
updatedAt = now,
|
updatedAt = now,
|
||||||
@@ -87,6 +96,10 @@ class FolderRepository @Inject constructor(
|
|||||||
folderDao.upsert(entity)
|
folderDao.upsert(entity)
|
||||||
return 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(
|
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
|
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.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
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.Folder
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -27,6 +40,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@@ -48,6 +62,25 @@ fun FolderListScreen(
|
|||||||
viewModel: FolderListViewModel = hiltViewModel(),
|
viewModel: FolderListViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -67,35 +100,86 @@ fun FolderListScreen(
|
|||||||
FolderListContent(
|
FolderListContent(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
onOpenFolder = onOpenFolder,
|
onOpenFolder = onOpenFolder,
|
||||||
|
onAddFolder = { pickFolderLauncher.launch(null) },
|
||||||
modifier = Modifier.padding(padding),
|
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
|
@Composable
|
||||||
private fun FolderListContent(
|
private fun FolderListContent(
|
||||||
uiState: FolderListUiState,
|
uiState: FolderListUiState,
|
||||||
onOpenFolder: (String) -> Unit,
|
onOpenFolder: (String) -> Unit,
|
||||||
|
onAddFolder: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
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 {
|
when {
|
||||||
uiState.folders.isEmpty() && uiState.isRefreshing -> {
|
uiState.folders.isEmpty() && uiState.isRefreshing -> {
|
||||||
Column(
|
Box(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
verticalArrangement = Arrangement.Center,
|
.weight(1f)
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
uiState.folders.isEmpty() -> {
|
uiState.folders.isEmpty() -> {
|
||||||
Column(
|
Box(
|
||||||
modifier = modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
.padding(24.dp),
|
.padding(24.dp),
|
||||||
verticalArrangement = Arrangement.Center,
|
contentAlignment = Alignment.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.no_folders_yet),
|
text = stringResource(R.string.no_folders_yet),
|
||||||
@@ -108,7 +192,9 @@ private fun FolderListContent(
|
|||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
@@ -118,6 +204,7 @@ private fun FolderListContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
+2
@@ -5,5 +5,7 @@ import com.vaultdrop.mobile.data.local.entity.FolderEntity
|
|||||||
data class FolderListUiState(
|
data class FolderListUiState(
|
||||||
val folders: List<FolderEntity> = emptyList(),
|
val folders: List<FolderEntity> = emptyList(),
|
||||||
val isRefreshing: Boolean = false,
|
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,
|
val error: String? = null,
|
||||||
)
|
)
|
||||||
+21
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.vaultdrop.mobile.auth.TokenProvider
|
import com.vaultdrop.mobile.auth.TokenProvider
|
||||||
import com.vaultdrop.mobile.data.remote.ApiException
|
import com.vaultdrop.mobile.data.remote.ApiException
|
||||||
import com.vaultdrop.mobile.data.repository.FolderRepository
|
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -18,6 +20,7 @@ import javax.inject.Inject
|
|||||||
class FolderListViewModel @Inject constructor(
|
class FolderListViewModel @Inject constructor(
|
||||||
private val folderRepository: FolderRepository,
|
private val folderRepository: FolderRepository,
|
||||||
private val tokenProvider: TokenProvider,
|
private val tokenProvider: TokenProvider,
|
||||||
|
private val deviceSync: DeviceSync,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(FolderListUiState())
|
private val _uiState = MutableStateFlow(FolderListUiState())
|
||||||
@@ -55,4 +58,22 @@ class FolderListViewModel @Inject constructor(
|
|||||||
_uiState.update { it.copy(isRefreshing = false) }
|
_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.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.AuthState
|
||||||
import com.vaultdrop.mobile.ui.auth.AuthViewModel
|
import com.vaultdrop.mobile.ui.auth.AuthViewModel
|
||||||
import com.vaultdrop.mobile.ui.auth.LoginScreen
|
import com.vaultdrop.mobile.ui.auth.LoginScreen
|
||||||
@@ -32,9 +34,15 @@ private fun SplashScreen() {
|
|||||||
@Composable
|
@Composable
|
||||||
fun VaultDropApp(
|
fun VaultDropApp(
|
||||||
authViewModel: AuthViewModel = hiltViewModel(),
|
authViewModel: AuthViewModel = hiltViewModel(),
|
||||||
|
syncViewModel: SyncViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val authState by authViewModel.authState.collectAsStateWithLifecycle()
|
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) {
|
when (authState) {
|
||||||
AuthState.Loading -> SplashScreen()
|
AuthState.Loading -> SplashScreen()
|
||||||
AuthState.SignedOut -> LoginScreen()
|
AuthState.SignedOut -> LoginScreen()
|
||||||
|
|||||||
Reference in New Issue
Block a user