outbox sync

This commit is contained in:
m
2026-09-13 09:17:26 +02:00
parent cc533a3b24
commit b494db9cf0
16 changed files with 462 additions and 20 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ Il n'y a **pas** de tests mobiles (pas de dossier `src/test` ni `src/androidTest
- `sync/DeviceSync.kt` + `SafScanner.kt` — sync device↔SAF : **two-pass walk** (listing hors transaction puis upserts Room dans une seule transaction), réconciliation `exists = 0` (jamais de suppression), single-flight via `Mutex` ; chaque nouvelle ressource découverte est journalisée dans l'outbox - `sync/DeviceSync.kt` + `SafScanner.kt` — sync device↔SAF : **two-pass walk** (listing hors transaction puis upserts Room dans une seule transaction), réconciliation `exists = 0` (jamais de suppression), single-flight via `Mutex` ; chaque nouvelle ressource découverte est journalisée dans l'outbox
- `saf/``SafUris`, `SafFolderCreator`, `FileMover` (relocalisation SAF `DocumentsContract.moveDocument`, repli métadonnée seule ; le déplacement est poussé en `move_resource`), `SafFileDeleter` (suppression SAF + `delete_resource` atomique) - `saf/``SafUris`, `SafFolderCreator`, `FileMover` (relocalisation SAF `DocumentsContract.moveDocument`, repli métadonnée seule ; le déplacement est poussé en `move_resource`), `SafFileDeleter` (suppression SAF + `delete_resource` atomique)
- `sync/OutboxSyncWorker.kt` — worker WorkManager (`enqueueUniqueWork` KEEP, single-flight, `NetworkType.CONNECTED`, backoff 30s) qui draine `pending_operations` vers `POST /sync/ops` (batch 20, dead-letter immédiat `failed` sur erreur 4xx non-idempotente — `attempts` diagnostic, purge synced > 7 jours) ; **schedulé** au login/restauration de session (`AuthViewModel`) et après chaque `syncAll()` (`SyncViewModel`) - `sync/OutboxSyncWorker.kt` — worker WorkManager (`enqueueUniqueWork` KEEP, single-flight, `NetworkType.CONNECTED`, backoff 30s) qui draine `pending_operations` vers `POST /sync/ops` (batch 20, dead-letter immédiat `failed` sur erreur 4xx non-idempotente — `attempts` diagnostic, purge synced > 7 jours) ; **schedulé** au login/restauration de session (`AuthViewModel`) et après chaque `syncAll()` (`SyncViewModel`)
- `sync/SyncViewModel.kt` — état du sync exposé à l'UI - `sync/SyncViewModel.kt` — état du sync exposé à l'UI (`SyncStatus` : marche en cours, ops en attente, dernières opérations) via le badge header `ui/components/SyncStatusBadge.kt` + `SyncOperationsDialog.kt` (« liste des sync »)
- `auth/` — session (login user + token paseto) : `SessionManager`, `SecureTokenStore`, `TokenProvider` - `auth/` — session (login user + token paseto) : `SessionManager`, `SecureTokenStore`, `TokenProvider`
- `ui/` — écrans Compose : `folderlist`, `folderdetail`, `document` (contenu PDF/image/texte + placeholder cloud-only), `search` (**recherche locale** via Room, sans endpoint serveur), `settings` (URL serveur + thème), `pdfbuilder` (multi-select → génération PDF), `auth` (login, mode local), `components`, `theme` - `ui/` — écrans Compose : `folderlist`, `folderdetail`, `document` (contenu PDF/image/texte + placeholder cloud-only), `search` (**recherche locale** via Room, sans endpoint serveur), `settings` (URL serveur + thème), `pdfbuilder` (multi-select → génération PDF), `auth` (login, mode local), `components`, `theme`
- `domain/` — stores de préférences: `DeviceIdentity`, `ActiveUserStore`, `LocalModeStore`, `ServerConfigStore`, `ThemePreferenceStore`, `GenerateId` (identifiants 32-hex) - `domain/` — stores de préférences: `DeviceIdentity`, `ActiveUserStore`, `LocalModeStore`, `ServerConfigStore`, `ThemePreferenceStore`, `GenerateId` (identifiants 32-hex)
+32 -17
View File
@@ -33,10 +33,10 @@ var ackOnlyOps = map[string]bool{
// SyncOperation is one outbox entry (shadow of mobile PendingOperationRow). // SyncOperation is one outbox entry (shadow of mobile PendingOperationRow).
type SyncOperation struct { type SyncOperation struct {
OperationID string `json:"operation_id"` OperationID string `json:"operation_id"`
RefType string `json:"ref_type"` RefType *string `json:"ref_type"`
RefID int64 `json:"ref_id"` RefID *int64 `json:"ref_id"`
ResourceID string `json:"resource_id"` ResourceID *string `json:"resource_id"`
ResourceType string `json:"resource_type"` ResourceType *string `json:"resource_type"`
Operation string `json:"operation"` Operation string `json:"operation"`
Payload json.RawMessage `json:"payload"` Payload json.RawMessage `json:"payload"`
} }
@@ -123,7 +123,7 @@ func validateSyncOp(op *SyncOperation) error {
if ackOnlyOps[op.Operation] { if ackOnlyOps[op.Operation] {
return nil return nil
} }
if !resourceIDPattern.MatchString(op.ResourceID) { if !resourceIDPattern.MatchString(derefString(op.ResourceID)) {
return errors.New("resource_id must be 32 lowercase hex chars") return errors.New("resource_id must be 32 lowercase hex chars")
} }
switch op.Operation { switch op.Operation {
@@ -131,16 +131,17 @@ func validateSyncOp(op *SyncOperation) error {
default: default:
return errors.New("unknown operation " + op.Operation) return errors.New("unknown operation " + op.Operation)
} }
if op.ResourceType != "folder" && op.ResourceType != "file" { if derefString(op.ResourceType) != "folder" && derefString(op.ResourceType) != "file" {
return errors.New("resource_type must be 'folder' or 'file'") return errors.New("resource_type must be 'folder' or 'file'")
} }
return nil return nil
} }
func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error { func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error {
resourceID := derefString(op.ResourceID)
switch op.Operation { switch op.Operation {
case OpCreateResource: case OpCreateResource:
exists, err := s.Repo.ExistsOwner(ownerID, op.ResourceID) exists, err := s.Repo.ExistsOwner(ownerID, resourceID)
if err != nil { if err != nil {
return err return err
} }
@@ -161,14 +162,14 @@ func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error {
return err return err
} }
} }
if op.ResourceType == "folder" { if derefString(op.ResourceType) == "folder" {
return s.Repo.InsertFolder(ownerID, op.ResourceID, p.Name, p.ParentResourceID) return s.Repo.InsertFolder(ownerID, resourceID, p.Name, p.ParentResourceID)
} }
mime := p.MimeType mime := p.MimeType
return s.Repo.InsertFile(ownerID, op.ResourceID, p.Name, p.ParentResourceID, 0, &mime, nullableString(p.Extension)) return s.Repo.InsertFile(ownerID, resourceID, p.Name, p.ParentResourceID, 0, &mime, nullableString(p.Extension))
case OpUpdateMetadata: case OpUpdateMetadata:
exists, err := s.Repo.ExistsOwner(ownerID, op.ResourceID) exists, err := s.Repo.ExistsOwner(ownerID, resourceID)
if err != nil { if err != nil {
return err return err
} }
@@ -182,10 +183,10 @@ func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error {
if p.Name == "" { if p.Name == "" {
return errors.New("payload.name required") return errors.New("payload.name required")
} }
return s.Repo.UpdateName(ownerID, op.ResourceID, p.Name) return s.Repo.UpdateName(ownerID, resourceID, p.Name)
case OpMoveResource: case OpMoveResource:
exists, err := s.Repo.ExistsOwner(ownerID, op.ResourceID) exists, err := s.Repo.ExistsOwner(ownerID, resourceID)
if err != nil { if err != nil {
return err return err
} }
@@ -196,13 +197,13 @@ func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error {
if err := json.Unmarshal(op.Payload, &p); err != nil { if err := json.Unmarshal(op.Payload, &p); err != nil {
return errors.New("invalid payload: " + err.Error()) return errors.New("invalid payload: " + err.Error())
} }
if p.ToFolderResourceID == op.ResourceID { if p.ToFolderResourceID == resourceID {
return errors.New("cannot move a resource into itself") return errors.New("cannot move a resource into itself")
} }
return s.Repo.MoveResource(ownerID, op.ResourceID, p.ToFolderResourceID) return s.Repo.MoveResource(ownerID, resourceID, p.ToFolderResourceID)
case OpDeleteResource: case OpDeleteResource:
return s.Repo.SyncDelete(ownerID, op.ResourceID) return s.Repo.SyncDelete(ownerID, resourceID)
default: default:
return errors.New("unknown operation " + op.Operation) return errors.New("unknown operation " + op.Operation)
@@ -210,7 +211,21 @@ func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error {
} }
func (s *Resources) recordApplied(deviceID string, op *SyncOperation) error { func (s *Resources) recordApplied(deviceID string, op *SyncOperation) error {
return s.Repository.Operations.Record(deviceID, op.OperationID, op.Operation, op.RefType, nullableInt64(op.RefID), op.ResourceID, op.Payload) return s.Repository.Operations.Record(deviceID, op.OperationID, op.Operation, derefString(op.RefType), nullableInt64(derefInt64(op.RefID)), derefString(op.ResourceID), op.Payload)
}
func derefString(v *string) string {
if v == nil {
return ""
}
return *v
}
func derefInt64(v *int64) int64 {
if v == nil {
return 0
}
return *v
} }
func nullableString(value string) *string { func nullableString(value string) *string {
+17 -1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
@@ -12,6 +13,21 @@
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:theme="@style/Theme.VaultDrop"> android:theme="@style/Theme.VaultDrop">
<!-- HiltWorkerFactory : désactive l'initialisation WorkManager par
défaut (androidx.startup) pour que Configuration.Provider
(VaultDropApplication) soit honoré — sinon le @HiltWorker ne peut
pas être instancié (NoSuchMethodException). -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.Query import androidx.room.Query
import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity
import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface PendingOperationDao { interface PendingOperationDao {
@@ -12,6 +13,14 @@ interface PendingOperationDao {
@Query("SELECT * FROM pending_operations WHERE status = 'pending' ORDER BY id ASC LIMIT :limit") @Query("SELECT * FROM pending_operations WHERE status = 'pending' ORDER BY id ASC LIMIT :limit")
suspend fun selectPending(limit: Int): List<PendingOperationEntity> suspend fun selectPending(limit: Int): List<PendingOperationEntity>
/** Dernières opérations (tous statuts), pour l'affichage UI « liste des sync ». */
@Query("SELECT * FROM pending_operations ORDER BY id DESC LIMIT :limit")
fun observeRecent(limit: Int): Flow<List<PendingOperationEntity>>
/** Nombre d'ops encore en attente de push (indicateur du header). */
@Query("SELECT COUNT(*) FROM pending_operations WHERE status = 'pending'")
fun observePendingCount(): Flow<Int>
@Insert @Insert
suspend fun insert(op: PendingOperationEntity): Long suspend fun insert(op: PendingOperationEntity): Long
@@ -1,8 +1,13 @@
package com.vaultdrop.mobile.features.sync package com.vaultdrop.mobile.features.sync
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import android.content.Context import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.vaultdrop.mobile.data.local.dao.PendingOperationDao
import com.vaultdrop.mobile.data.local.entity.PendingOpStatus
import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity
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.data.repository.SaveFolderInput
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -11,8 +16,11 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@@ -28,11 +36,16 @@ import javax.inject.Inject
* - un échec n'interrompt pas la boucle (retry au tick suivant) ; * - un échec n'interrompt pas la boucle (retry au tick suivant) ;
* - le scope du ViewModel arrête la boucle (cancel) à la destruction du * - le scope du ViewModel arrête la boucle (cancel) à la destruction du
* store → le walk en cours s'arrête via `ensureActive()` du scanner. * store → le walk en cours s'arrête via `ensureActive()` du scanner.
*
* **Observabilité** : expose [syncStatus] pour le badge du header — marche SAF
* en cours ([SyncStatus.syncing]), nombre d'ops outbox en attente, et les
* dernières opérations ([SyncStatus.operations]) pour la « liste des sync ».
*/ */
@HiltViewModel @HiltViewModel
class SyncViewModel @Inject constructor( class SyncViewModel @Inject constructor(
private val deviceSync: DeviceSync, private val deviceSync: DeviceSync,
private val folderRepository: FolderRepository, private val folderRepository: FolderRepository,
private val pendingOperationDao: PendingOperationDao,
@ApplicationContext private val appContext: Context, @ApplicationContext private val appContext: Context,
) : ViewModel() { ) : ViewModel() {
@@ -43,9 +56,40 @@ class SyncViewModel @Inject constructor(
val error: String? = null, val error: String? = null,
) )
/** État de la synchro affiché dans le header (indicateur + liste). */
data class SyncStatus(
/** Une marche SAF ou un drain outbox est en cours. */
val syncing: Boolean = false,
/** Ops en attente de push (jamais poussées, outbox non vide). */
val pending: Int = 0,
/** Ops dead-lettrées (dernières seulement, vu la fenêtre UI). */
val failed: Int = 0,
/** Dernières opérations (tous statuts) — « liste des sync ». */
val operations: List<PendingOperationEntity> = emptyList(),
)
private val _importState = MutableStateFlow(ImportState()) private val _importState = MutableStateFlow(ImportState())
val importState: StateFlow<ImportState> = _importState.asStateFlow() val importState: StateFlow<ImportState> = _importState.asStateFlow()
private val _walkInProgress = MutableStateFlow(false)
/** Activity de synthèse : marche SAF + drain outbox + file + dernière ops. */
val syncStatus: StateFlow<SyncStatus> = combine(
_walkInProgress,
WorkManager.getInstance(appContext).getWorkInfosForUniqueWorkFlow(OutboxSyncWorker.NAME),
pendingOperationDao.observePendingCount(),
pendingOperationDao.observeRecent(RECENT_LIMIT),
) { walking, workInfos, pending, operations ->
// RUNNING = drain en cours ; ENQUEUED = planifié (attente réseau/backoff).
val draining = workInfos.any { it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED }
SyncStatus(
syncing = walking || draining,
pending = pending,
failed = operations.count { it.status == PendingOpStatus.FAILED },
operations = operations,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SyncStatus())
/** Démarre la boucle une seule fois (idempotent). */ /** Démarre la boucle une seule fois (idempotent). */
fun ensureStarted() { fun ensureStarted() {
if (loopJob?.isActive == true) return if (loopJob?.isActive == true) return
@@ -54,6 +98,7 @@ class SyncViewModel @Inject constructor(
// répondre avant de lancer un walk potentiellement long. // répondre avant de lancer un walk potentiellement long.
delay(FIRST_DELAY_MS) delay(FIRST_DELAY_MS)
while (isActive) { while (isActive) {
_walkInProgress.value = true
runCatching { deviceSync.syncAll() } runCatching { deviceSync.syncAll() }
.onSuccess { results -> .onSuccess { results ->
if (results.isNotEmpty()) Timber.d("syncAll: %s", results) if (results.isNotEmpty()) Timber.d("syncAll: %s", results)
@@ -62,6 +107,7 @@ class SyncViewModel @Inject constructor(
OutboxSyncWorker.enqueue(appContext) OutboxSyncWorker.enqueue(appContext)
} }
.onFailure { e -> Timber.w(e, "syncAll failed, retrying later") } .onFailure { e -> Timber.w(e, "syncAll failed, retrying later") }
_walkInProgress.value = false
delay(INTERVAL_MS) delay(INTERVAL_MS)
} }
} }
@@ -81,6 +127,7 @@ class SyncViewModel @Inject constructor(
if (_importState.value.isImporting) return if (_importState.value.isImporting) return
viewModelScope.launch { viewModelScope.launch {
_importState.value = ImportState(isImporting = true) _importState.value = ImportState(isImporting = true)
_walkInProgress.value = true
try { try {
val saved = folderRepository.saveFolder( val saved = folderRepository.saveFolder(
SaveFolderInput(uri = uri, name = name, exists = true), SaveFolderInput(uri = uri, name = name, exists = true),
@@ -94,6 +141,7 @@ class SyncViewModel @Inject constructor(
_importState.value = ImportState(error = e.message ?: "Erreur lors de l'ajout du dossier") _importState.value = ImportState(error = e.message ?: "Erreur lors de l'ajout du dossier")
} finally { } finally {
_importState.value = _importState.value.copy(isImporting = false) _importState.value = _importState.value.copy(isImporting = false)
_walkInProgress.value = false
} }
} }
} }
@@ -107,5 +155,7 @@ class SyncViewModel @Inject constructor(
const val INTERVAL_MS = 30_000L const val INTERVAL_MS = 30_000L
/** Délai avant le premier cycle (au démarrage de l'app). */ /** Délai avant le premier cycle (au démarrage de l'app). */
const val FIRST_DELAY_MS = 2_000L const val FIRST_DELAY_MS = 2_000L
/** Fenêtre d'affichage de la « liste des sync ». */
const val RECENT_LIMIT = 50
} }
} }
@@ -0,0 +1,157 @@
package com.vaultdrop.mobile.ui.components
import android.text.format.DateUtils
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.data.local.entity.PendingOpStatus
import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity
import com.vaultdrop.mobile.features.sync.SyncViewModel
private val StatusPending = Color(0xFF1E88E5)
private val StatusSynced = Color(0xFF2E7D32)
private val StatusFailed = Color(0xFFC62828)
/**
* Dialogue « liste des sync » : dernières opérations de l'outbox (tous statuts),
* avec un résumé (en attente / échouées) en tête de liste.
*/
@Composable
internal fun SyncOperationsDialog(
status: SyncViewModel.SyncStatus,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.sync_list_title)) },
text = {
Column {
SyncListSummary(status)
if (status.operations.isEmpty()) {
Text(
text = stringResource(R.string.sync_list_empty),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp),
)
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 360.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
items(status.operations, key = { it.id }) { op ->
SyncOperationRow(op)
}
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.sync_list_close))
}
},
)
}
@Composable
private fun SyncListSummary(status: SyncViewModel.SyncStatus) {
val summary = buildString {
append(stringResource(R.string.sync_summary_total, status.operations.size))
if (status.pending > 0) {
append(" · ")
append(stringResource(R.string.sync_summary_pending, status.pending))
}
if (status.failed > 0) {
append(" · ")
append(stringResource(R.string.sync_summary_failed, status.failed))
}
}
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
}
@Composable
private fun SyncOperationRow(op: PendingOperationEntity) {
val (dotColor, statusLabel) = when (op.status) {
PendingOpStatus.PENDING -> StatusPending to stringResource(R.string.sync_status_pending)
PendingOpStatus.SYNCED -> StatusSynced to stringResource(R.string.sync_status_synced)
else -> StatusFailed to stringResource(R.string.sync_status_failed)
}
val opLabel = operationLabel(op.operation)
val time = DateUtils.getRelativeTimeSpanString(op.createdAt).toString()
val shortId = op.resourceId?.take(8).orEmpty()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(modifier = Modifier.size(8.dp).background(dotColor, CircleShape))
Column(modifier = Modifier.weight(1f)) {
Text(
text = opLabel,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
Text(
text = listOfNotNull(
op.resourceType?.takeIf { it.isNotBlank() },
shortId.ifBlank { null },
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = statusLabel,
style = MaterialTheme.typography.labelSmall,
color = dotColor,
fontWeight = FontWeight.SemiBold,
)
Text(
text = time,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun operationLabel(operation: String): String = when (operation) {
"create_resource" -> stringResource(R.string.sync_op_create)
"move_resource" -> stringResource(R.string.sync_op_move)
"delete_resource" -> stringResource(R.string.sync_op_delete)
"update_metadata" -> stringResource(R.string.sync_op_update)
else -> operation
}
@@ -0,0 +1,143 @@
package com.vaultdrop.mobile.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.features.sync.SyncViewModel
private val SyncingAccent = Color(0xFF1E88E5)
private val PendingAmber = Color(0xFFF9A825)
/**
* Pilule de statut de synchro affichée dans le header : spinner quand une marche
* SAF ou un drain outbox est en cours, compteur d'ops en attente sinon, coche
* quand tout est poussé. Un appui ouvre la « liste des sync » (dernières ops).
*/
@Composable
fun SyncStatusBadge(
status: SyncViewModel.SyncStatus,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val (accent, label, showSpinner) = when {
status.syncing -> {
Triple(SyncingAccent, stringResource(R.string.sync_syncing), true)
}
status.pending > 0 -> {
val label = if (status.pending == 1) {
stringResource(R.string.sync_pending_count_one)
} else {
stringResource(R.string.sync_pending_count, status.pending)
}
Triple(PendingAmber, label, false)
}
else -> Triple(
MaterialTheme.colorScheme.primary,
stringResource(R.string.sync_idle),
false,
)
}
val shape = RoundedCornerShape(percent = 50)
val tapLabel = stringResource(R.string.sync_tap_to_view)
Row(
modifier = modifier
.clip(shape)
.clickable(
role = Role.Button,
onClickLabel = tapLabel,
onClick = onClick,
)
.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (showSpinner) {
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 2.dp,
color = accent,
)
} else if (status.pending > 0) {
Box(
modifier = Modifier
.size(14.dp)
.background(accent, CircleShape),
contentAlignment = Alignment.Center,
) {
Text(
text = status.pending.coerceAtMost(99).toString(),
style = MaterialTheme.typography.labelSmall,
color = Color.White,
fontWeight = FontWeight.Bold,
)
}
} else {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = accent,
)
}
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/**
* Composable prêt-à-poser dans un header : badge + dialogue « liste des sync ».
* Le dialogue est géré localement (état `showList`), aucun wiring externe.
*/
@Composable
fun SyncStatusAction(
syncViewModel: SyncViewModel,
modifier: Modifier = Modifier,
) {
val status by syncViewModel.syncStatus.collectAsStateWithLifecycle()
var showList by remember { mutableStateOf(false) }
SyncStatusBadge(
status = status,
onClick = { showList = true },
modifier = modifier,
)
if (showList) {
SyncOperationsDialog(
status = status,
onDismiss = { showList = false },
)
}
}
@@ -29,7 +29,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vaultdrop.mobile.R import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
import com.vaultdrop.mobile.features.sync.SyncViewModel
import com.vaultdrop.mobile.ui.components.ServerStatusBadge import com.vaultdrop.mobile.ui.components.ServerStatusBadge
import com.vaultdrop.mobile.ui.components.SyncStatusAction
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar import com.vaultdrop.mobile.ui.navigation.FloatingNavBar
import com.vaultdrop.mobile.ui.navigation.NavTab import com.vaultdrop.mobile.ui.navigation.NavTab
@@ -39,6 +41,7 @@ fun DashboardScreen(
selectedTab: NavTab, selectedTab: NavTab,
onTabSelected: (NavTab) -> Unit, onTabSelected: (NavTab) -> Unit,
connectionStatusViewModel: ConnectionStatusViewModel, connectionStatusViewModel: ConnectionStatusViewModel,
syncViewModel: SyncViewModel,
onOpenReview: () -> Unit, onOpenReview: () -> Unit,
viewModel: DashboardViewModel = hiltViewModel(), viewModel: DashboardViewModel = hiltViewModel(),
) { ) {
@@ -50,6 +53,7 @@ fun DashboardScreen(
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.dashboard)) }, title = { Text(stringResource(R.string.dashboard)) },
actions = { actions = {
SyncStatusAction(syncViewModel = syncViewModel)
ServerStatusBadge( ServerStatusBadge(
status = connectionStatus, status = connectionStatus,
onClick = connectionStatusViewModel::checkNow, onClick = connectionStatusViewModel::checkNow,
@@ -61,7 +61,9 @@ import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.domain.FileCategory import com.vaultdrop.mobile.domain.FileCategory
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
import com.vaultdrop.mobile.features.sync.SyncViewModel
import com.vaultdrop.mobile.ui.components.ServerStatusBadge import com.vaultdrop.mobile.ui.components.ServerStatusBadge
import com.vaultdrop.mobile.ui.components.SyncStatusAction
import com.vaultdrop.mobile.ui.components.categoryValue import com.vaultdrop.mobile.ui.components.categoryValue
import com.vaultdrop.mobile.ui.document.content.DocumentContentViewer import com.vaultdrop.mobile.ui.document.content.DocumentContentViewer
import com.vaultdrop.mobile.ui.document.content.ImageViewer import com.vaultdrop.mobile.ui.document.content.ImageViewer
@@ -90,6 +92,7 @@ fun DocumentViewerScreen(
initialResourceId: String, initialResourceId: String,
onBack: () -> Unit, onBack: () -> Unit,
connectionStatusViewModel: ConnectionStatusViewModel, connectionStatusViewModel: ConnectionStatusViewModel,
syncViewModel: SyncViewModel,
viewModel: DocumentViewerViewModel = hiltViewModel(), viewModel: DocumentViewerViewModel = hiltViewModel(),
) { ) {
val documents by viewModel.documents.collectAsStateWithLifecycle() val documents by viewModel.documents.collectAsStateWithLifecycle()
@@ -171,6 +174,7 @@ fun DocumentViewerScreen(
} }
}, },
actions = { actions = {
SyncStatusAction(syncViewModel = syncViewModel)
ServerStatusBadge( ServerStatusBadge(
status = connectionStatus, status = connectionStatus,
onClick = connectionStatusViewModel::checkNow, onClick = connectionStatusViewModel::checkNow,
@@ -50,12 +50,14 @@ import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.data.local.entity.FolderEntity import com.vaultdrop.mobile.data.local.entity.FolderEntity
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
import com.vaultdrop.mobile.features.sync.SyncViewModel
import com.vaultdrop.mobile.ui.components.FileCategoryIcon import com.vaultdrop.mobile.ui.components.FileCategoryIcon
import com.vaultdrop.mobile.ui.components.FolderNameDialog import com.vaultdrop.mobile.ui.components.FolderNameDialog
import com.vaultdrop.mobile.ui.components.MoveFolderPickerDialog import com.vaultdrop.mobile.ui.components.MoveFolderPickerDialog
import com.vaultdrop.mobile.ui.components.SelectionState import com.vaultdrop.mobile.ui.components.SelectionState
import com.vaultdrop.mobile.ui.components.SelectionStatusIcon import com.vaultdrop.mobile.ui.components.SelectionStatusIcon
import com.vaultdrop.mobile.ui.components.ServerStatusBadge import com.vaultdrop.mobile.ui.components.ServerStatusBadge
import com.vaultdrop.mobile.ui.components.SyncStatusAction
import com.vaultdrop.mobile.ui.components.rememberSelectionState import com.vaultdrop.mobile.ui.components.rememberSelectionState
import java.util.Locale import java.util.Locale
@@ -68,6 +70,7 @@ fun FolderDetailScreen(
onOpenDocument: (String) -> Unit, onOpenDocument: (String) -> Unit,
onBuildPdf: (List<String>) -> Unit, onBuildPdf: (List<String>) -> Unit,
connectionStatusViewModel: ConnectionStatusViewModel, connectionStatusViewModel: ConnectionStatusViewModel,
syncViewModel: SyncViewModel,
viewModel: FolderDetailViewModel = hiltViewModel(), viewModel: FolderDetailViewModel = hiltViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -161,6 +164,7 @@ fun FolderDetailScreen(
contentDescription = stringResource(R.string.create_folder), contentDescription = stringResource(R.string.create_folder),
) )
} }
SyncStatusAction(syncViewModel = syncViewModel)
ServerStatusBadge( ServerStatusBadge(
status = connectionStatus, status = connectionStatus,
onClick = connectionStatusViewModel::checkNow, onClick = connectionStatusViewModel::checkNow,
@@ -77,6 +77,7 @@ import com.vaultdrop.mobile.ui.components.FolderNameDialog
import com.vaultdrop.mobile.ui.components.SelectionState import com.vaultdrop.mobile.ui.components.SelectionState
import com.vaultdrop.mobile.ui.components.SelectionStatusIcon import com.vaultdrop.mobile.ui.components.SelectionStatusIcon
import com.vaultdrop.mobile.ui.components.ServerStatusBadge import com.vaultdrop.mobile.ui.components.ServerStatusBadge
import com.vaultdrop.mobile.ui.components.SyncStatusAction
import com.vaultdrop.mobile.ui.components.rememberSelectionState import com.vaultdrop.mobile.ui.components.rememberSelectionState
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar import com.vaultdrop.mobile.ui.navigation.FloatingNavBar
import com.vaultdrop.mobile.ui.navigation.MoveTargetBar import com.vaultdrop.mobile.ui.navigation.MoveTargetBar
@@ -212,6 +213,7 @@ fun FolderListScreen(
}, },
actions = { actions = {
if (!uiState.moveMode && !selection.active) { if (!uiState.moveMode && !selection.active) {
SyncStatusAction(syncViewModel = syncViewModel)
ServerStatusBadge( ServerStatusBadge(
status = connectionStatus, status = connectionStatus,
onClick = connectionStatusViewModel::checkNow, onClick = connectionStatusViewModel::checkNow,
@@ -106,6 +106,7 @@ fun NavGraph(
onOpenDocument = { id -> navController.navigate(Routes.document(id)) }, onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
onBuildPdf = { ids -> navController.navigate(Routes.pdfBuilder(ids)) }, onBuildPdf = { ids -> navController.navigate(Routes.pdfBuilder(ids)) },
connectionStatusViewModel = connectionStatusViewModel, connectionStatusViewModel = connectionStatusViewModel,
syncViewModel = syncViewModel,
) )
} }
composable(Routes.SETTINGS) { composable(Routes.SETTINGS) {
@@ -115,6 +116,7 @@ fun NavGraph(
onOpenWatchedFolders = onOpenWatchedFolders, onOpenWatchedFolders = onOpenWatchedFolders,
authViewModel = authViewModel, authViewModel = authViewModel,
connectionStatusViewModel = connectionStatusViewModel, connectionStatusViewModel = connectionStatusViewModel,
syncViewModel = syncViewModel,
) )
} }
composable(Routes.DASHBOARD) { composable(Routes.DASHBOARD) {
@@ -122,6 +124,7 @@ fun NavGraph(
selectedTab = selectedTab, selectedTab = selectedTab,
onTabSelected = onTabSelected, onTabSelected = onTabSelected,
connectionStatusViewModel = connectionStatusViewModel, connectionStatusViewModel = connectionStatusViewModel,
syncViewModel = syncViewModel,
onOpenReview = { navController.navigate(Routes.REVIEW) }, onOpenReview = { navController.navigate(Routes.REVIEW) },
) )
} }
@@ -129,6 +132,7 @@ fun NavGraph(
SwipeReviewScreen( SwipeReviewScreen(
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
connectionStatusViewModel = connectionStatusViewModel, connectionStatusViewModel = connectionStatusViewModel,
syncViewModel = syncViewModel,
) )
} }
composable(Routes.WATCHED_FOLDERS) { composable(Routes.WATCHED_FOLDERS) {
@@ -153,6 +157,7 @@ fun NavGraph(
onOpenDocument = { id -> navController.navigate(Routes.document(id)) }, onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
onBuildPdf = { ids -> navController.navigate(Routes.pdfBuilder(ids)) }, onBuildPdf = { ids -> navController.navigate(Routes.pdfBuilder(ids)) },
connectionStatusViewModel = connectionStatusViewModel, connectionStatusViewModel = connectionStatusViewModel,
syncViewModel = syncViewModel,
) )
} }
composable( composable(
@@ -168,6 +173,7 @@ fun NavGraph(
initialResourceId = documentId, initialResourceId = documentId,
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
connectionStatusViewModel = connectionStatusViewModel, connectionStatusViewModel = connectionStatusViewModel,
syncViewModel = syncViewModel,
) )
} }
composable( composable(
@@ -43,7 +43,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vaultdrop.mobile.R import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
import com.vaultdrop.mobile.features.sync.SyncViewModel
import com.vaultdrop.mobile.ui.components.ServerStatusBadge import com.vaultdrop.mobile.ui.components.ServerStatusBadge
import com.vaultdrop.mobile.ui.components.SyncStatusAction
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -51,6 +53,7 @@ import kotlinx.coroutines.launch
fun SwipeReviewScreen( fun SwipeReviewScreen(
onBack: () -> Unit, onBack: () -> Unit,
connectionStatusViewModel: ConnectionStatusViewModel, connectionStatusViewModel: ConnectionStatusViewModel,
syncViewModel: SyncViewModel,
viewModel: SwipeReviewViewModel = hiltViewModel(), viewModel: SwipeReviewViewModel = hiltViewModel(),
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
@@ -88,6 +91,7 @@ fun SwipeReviewScreen(
} }
}, },
actions = { actions = {
SyncStatusAction(syncViewModel = syncViewModel)
ServerStatusBadge( ServerStatusBadge(
status = connectionStatus, status = connectionStatus,
onClick = connectionStatusViewModel::checkNow, onClick = connectionStatusViewModel::checkNow,
@@ -56,11 +56,13 @@ import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.domain.FileCategory import com.vaultdrop.mobile.domain.FileCategory
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
import com.vaultdrop.mobile.features.sync.SyncViewModel
import com.vaultdrop.mobile.ui.components.FileCategoryIcon import com.vaultdrop.mobile.ui.components.FileCategoryIcon
import com.vaultdrop.mobile.ui.components.MoveFolderPickerDialog import com.vaultdrop.mobile.ui.components.MoveFolderPickerDialog
import com.vaultdrop.mobile.ui.components.SelectionState import com.vaultdrop.mobile.ui.components.SelectionState
import com.vaultdrop.mobile.ui.components.SelectionStatusIcon import com.vaultdrop.mobile.ui.components.SelectionStatusIcon
import com.vaultdrop.mobile.ui.components.ServerStatusBadge import com.vaultdrop.mobile.ui.components.ServerStatusBadge
import com.vaultdrop.mobile.ui.components.SyncStatusAction
import com.vaultdrop.mobile.ui.components.color import com.vaultdrop.mobile.ui.components.color
import com.vaultdrop.mobile.ui.components.icon import com.vaultdrop.mobile.ui.components.icon
import com.vaultdrop.mobile.ui.components.rememberSelectionState import com.vaultdrop.mobile.ui.components.rememberSelectionState
@@ -77,6 +79,7 @@ fun SearchScreen(
onOpenDocument: (String) -> Unit, onOpenDocument: (String) -> Unit,
onBuildPdf: (List<String>) -> Unit, onBuildPdf: (List<String>) -> Unit,
connectionStatusViewModel: ConnectionStatusViewModel, connectionStatusViewModel: ConnectionStatusViewModel,
syncViewModel: SyncViewModel,
viewModel: SearchViewModel = hiltViewModel(), viewModel: SearchViewModel = hiltViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -123,6 +126,7 @@ fun SearchScreen(
}, },
actions = { actions = {
if (!selection.active) { if (!selection.active) {
SyncStatusAction(syncViewModel = syncViewModel)
ServerStatusBadge( ServerStatusBadge(
status = connectionStatus, status = connectionStatus,
onClick = connectionStatusViewModel::checkNow, onClick = connectionStatusViewModel::checkNow,
@@ -49,10 +49,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vaultdrop.mobile.R import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.domain.ThemePreference import com.vaultdrop.mobile.domain.ThemePreference
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
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.authErrorResFor import com.vaultdrop.mobile.ui.auth.authErrorResFor
import com.vaultdrop.mobile.ui.components.ServerStatusBadge import com.vaultdrop.mobile.ui.components.ServerStatusBadge
import com.vaultdrop.mobile.ui.components.SyncStatusAction
import com.vaultdrop.mobile.ui.components.WatchedFoldersBadge import com.vaultdrop.mobile.ui.components.WatchedFoldersBadge
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar import com.vaultdrop.mobile.ui.navigation.FloatingNavBar
import com.vaultdrop.mobile.ui.navigation.NavTab import com.vaultdrop.mobile.ui.navigation.NavTab
@@ -65,6 +67,7 @@ fun SettingsScreen(
onOpenWatchedFolders: () -> Unit, onOpenWatchedFolders: () -> Unit,
authViewModel: AuthViewModel, authViewModel: AuthViewModel,
connectionStatusViewModel: ConnectionStatusViewModel, connectionStatusViewModel: ConnectionStatusViewModel,
syncViewModel: SyncViewModel,
viewModel: SettingsViewModel = hiltViewModel(), viewModel: SettingsViewModel = hiltViewModel(),
) { ) {
val theme by viewModel.theme.collectAsStateWithLifecycle() val theme by viewModel.theme.collectAsStateWithLifecycle()
@@ -88,6 +91,7 @@ fun SettingsScreen(
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.settings)) }, title = { Text(stringResource(R.string.settings)) },
actions = { actions = {
SyncStatusAction(syncViewModel = syncViewModel)
ServerStatusBadge( ServerStatusBadge(
status = connectionStatus, status = connectionStatus,
onClick = connectionStatusViewModel::checkNow, onClick = connectionStatusViewModel::checkNow,
@@ -129,6 +129,26 @@
<string name="status_local">Mode local</string> <string name="status_local">Mode local</string>
<string name="status_tap_to_recheck">Statut serveur — appuyer pour vérifier</string> <string name="status_tap_to_recheck">Statut serveur — appuyer pour vérifier</string>
<!-- Statut de synchronisation (badge + liste outbox) -->
<string name="sync_syncing">Synchro…</string>
<string name="sync_pending_count">%1$d en attente</string>
<string name="sync_pending_count_one">1 en attente</string>
<string name="sync_idle">À jour</string>
<string name="sync_tap_to_view">Synchro — appuyer pour voir la liste des ops</string>
<string name="sync_list_title">Liste des sync</string>
<string name="sync_list_empty">Aucune opération pour le moment.</string>
<string name="sync_list_close">Fermer</string>
<string name="sync_summary_total">%1$d opérations</string>
<string name="sync_summary_pending">%1$d en attente</string>
<string name="sync_summary_failed">%1$d échouée(s)</string>
<string name="sync_status_pending">En attente</string>
<string name="sync_status_synced">Poussée</string>
<string name="sync_status_failed">Échouée</string>
<string name="sync_op_create">Création de ressource</string>
<string name="sync_op_move">Déplacement</string>
<string name="sync_op_delete">Suppression</string>
<string name="sync_op_update">Mise à jour</string>
<!-- Sélection multi-fichiers + assemblage PDF --> <!-- Sélection multi-fichiers + assemblage PDF -->
<string name="selection_count">%1$d sélectionné(s)</string> <string name="selection_count">%1$d sélectionné(s)</string>
<string name="selection_assemble">Assembler en PDF</string> <string name="selection_assemble">Assembler en PDF</string>