update synchro agenda
EBO <eric.bouhana@softalys.com> committé le 2026-08-29 11:45
821ceeb418356255322b2bce012fc308255eea7f
1 parent(s)
17 fichiers modifiés
+503
-88
M
README.md
+1
-1
@@ -48,7 +48,7 @@ PicLead ne contient **aucun écran agenda** : les rendez-vous et réservations d
| 48 | 48 | **Configuration dans l’app :** Paramètres → section **Calendriers** (visible une fois connecté) : |
| 49 | 49 | - Toggle **« Mes RDV »** → crée/lie le calendrier local `PicLead — Mes RDV` ; les événements qui y sont créés/modifiés deviennent des RDV PicLead à la sync. |
| 50 | 50 | - Si des identifiants sont configurés, la liste des **ressources** (salles, matériel, véhicules) du serveur s’affiche avec un toggle par ressource active ; les ressources **inactives** sont grisées (non sélectionnables). Activer une ressource crée le calendrier `PicLead — {Salle|Matériel|Véhicule} {nom}` ; un événement qui y est créé devient une **réservation Active** poussée au serveur. |
| 51 | -- Désactiver un toggle demande **confirmation** avant de retirer la liaison. Le retrait supprime uniquement la liaison locale (sync arrêtée pour ce calendrier) — l’API `CalendarBridge` n’expose pas de suppression de calendrier Android, le calendrier créé reste donc visible (vide, non synchronisé) dans l’app Agenda système. | |
| 51 | +- Désactiver un toggle demande **confirmation** avant retrait. Le retrait **supprime réellement le calendrier local et ses événements de l’appareil** (`CalendarBridge.deleteCalendar`, suppression des événements en cascade par Android), puis retire la liaison : le calendrier disparaît de l’app Agenda système. Les **données serveur ne sont pas supprimées** — vos RDV et réservations restent dans PicLead. Si l’autorisation Agenda a été révoquée entre-temps, la suppression échoue proprement : la liaison est retirée malgré tout et un message le signale dans la section Calendriers. | |
| 52 | 52 | |
| 53 | 53 | **Sync et conflits :** le bouton **Sync** pousse aussi les événements Agenda liés (RDV + réservations) puis tire le delta serveur (RDV, réservations actives, indisponibilités en lecture). Un bandeau signale un **calendrier local en avance** (événements pas encore poussés). Si une réservation entre en conflit avec le serveur (**HTTP 409**, chevauchement), PicLead conserve l’événement local, retire le blocage serveur pour affichage, puis propose un dialog **« Annuler ma réservation ? »** : |
| 54 | 54 | - **Oui** → la réservation locale et son événement Agenda sont supprimés. |
M
android/app/src/main/java/fr/ebii/card2vcf/sync/AgendaSyncCoordinator.kt
+11
-3
@@ -103,7 +103,7 @@ class AgendaSyncCoordinator(
| 103 | 103 | cibleId = cibleId, |
| 104 | 104 | debutMs = snap.debutMs, |
| 105 | 105 | finMs = snap.finMs, |
| 106 | - motif = snap.descriptionBody, | |
| 106 | + motif = snap.motifReservation(), | |
| 107 | 107 | calendarEventId = eventId, |
| 108 | 108 | updatedAt = System.currentTimeMillis(), |
| 109 | 109 | dirtyLocal = true, |
@@ -117,7 +117,7 @@ class AgendaSyncCoordinator(
| 117 | 117 | val updated = local.copy( |
| 118 | 118 | debutMs = snap.debutMs, |
| 119 | 119 | finMs = snap.finMs, |
| 120 | - motif = snap.descriptionBody, | |
| 120 | + motif = snap.motifReservation(), | |
| 121 | 121 | updatedAt = System.currentTimeMillis(), |
| 122 | 122 | dirtyLocal = true, |
| 123 | 123 | ) |
@@ -144,7 +144,15 @@ class AgendaSyncCoordinator(
| 144 | 144 | } |
| 145 | 145 | |
| 146 | 146 | private fun ReservationEntity.differsFrom(snap: CalendarEventSnapshot): Boolean = |
| 147 | - motif != snap.descriptionBody || debutMs != snap.debutMs || finMs != snap.finMs | |
| 147 | + motif != snap.motifReservation() || debutMs != snap.debutMs || finMs != snap.finMs | |
| 148 | + | |
| 149 | + /** | |
| 150 | + * Motif d'une réservation poussée : le TITRE de l'événement (symétrique du | |
| 151 | + * sens descendant, qui affiche le motif comme titre — cf. `roomToAgenda`), | |
| 152 | + * la description en secours. Le serveur refuse un motif vide. | |
| 153 | + */ | |
| 154 | + private fun CalendarEventSnapshot.motifReservation(): String = | |
| 155 | + title.ifBlank { descriptionBody } | |
| 148 | 156 | |
| 149 | 157 | /** Remplace toute op en attente pour cette entité locale avant d'insérer la nouvelle (évite l'accumulation). */ |
| 150 | 158 | private suspend fun enqueueOp(entityType: String, op: String, localId: Long, serverId: String?, payloadJson: String) { |
M
android/app/src/main/java/fr/ebii/card2vcf/sync/CalendarBridge.kt
+21
-5
@@ -3,6 +3,7 @@ package fr.ebii.card2vcf.sync
| 3 | 3 | import android.content.ContentResolver |
| 4 | 4 | import android.content.ContentUris |
| 5 | 5 | import android.content.ContentValues |
| 6 | +import android.net.Uri | |
| 6 | 7 | import android.provider.CalendarContract |
| 7 | 8 | import java.util.TimeZone |
| 8 | 9 |
@@ -52,6 +53,9 @@ object CalendarEventTitles {
| 52 | 53 | interface CalendarBridgeApi { |
| 53 | 54 | fun ensureLocalCalendar(displayName: String): Long |
| 54 | 55 | |
| 56 | + /** Supprime le calendrier local et, en cascade côté Android, tous ses événements. */ | |
| 57 | + fun deleteCalendar(calendarId: Long) | |
| 58 | + | |
| 55 | 59 | fun listEvents(calendarId: Long): List<CalendarEventSnapshot> |
| 56 | 60 | |
| 57 | 61 | fun upsertEvent(calendarId: Long, snap: CalendarEventSnapshot): Long |
@@ -70,11 +74,7 @@ class CalendarBridge(private val resolver: ContentResolver) : CalendarBridgeApi
| 70 | 74 | override fun ensureLocalCalendar(displayName: String): Long { |
| 71 | 75 | findCalendarId(displayName)?.let { return it } |
| 72 | 76 | |
| 73 | - val uri = CalendarContract.Calendars.CONTENT_URI.buildUpon() | |
| 74 | - .appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true") | |
| 75 | - .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT_NAME) | |
| 76 | - .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL) | |
| 77 | - .build() | |
| 77 | + val uri = asSyncAdapter(CalendarContract.Calendars.CONTENT_URI) | |
| 78 | 78 | |
| 79 | 79 | val values = ContentValues().apply { |
| 80 | 80 | put(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT_NAME) |
@@ -93,6 +93,15 @@ class CalendarBridge(private val resolver: ContentResolver) : CalendarBridgeApi
| 93 | 93 | return ContentUris.parseId(created) |
| 94 | 94 | } |
| 95 | 95 | |
| 96 | + /** | |
| 97 | + * Supprime le calendrier local [calendarId] de l'appareil. Le fournisseur Agenda supprime ses | |
| 98 | + * événements en cascade. Un id inconnu est sans effet (`delete` retourne 0). | |
| 99 | + */ | |
| 100 | + override fun deleteCalendar(calendarId: Long) { | |
| 101 | + val uri = asSyncAdapter(ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId)) | |
| 102 | + resolver.delete(uri, null, null) | |
| 103 | + } | |
| 104 | + | |
| 96 | 105 | override fun listEvents(calendarId: Long): List<CalendarEventSnapshot> { |
| 97 | 106 | val projection = arrayOf( |
| 98 | 107 | CalendarContract.Events._ID, |
@@ -157,6 +166,13 @@ class CalendarBridge(private val resolver: ContentResolver) : CalendarBridgeApi
| 157 | 166 | resolver.delete(uri, null, null) |
| 158 | 167 | } |
| 159 | 168 | |
| 169 | + /** Écriture sur `Calendars` en mode sync-adapter : mêmes compte/type qu'à la création. */ | |
| 170 | + private fun asSyncAdapter(uri: Uri): Uri = uri.buildUpon() | |
| 171 | + .appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true") | |
| 172 | + .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT_NAME) | |
| 173 | + .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL) | |
| 174 | + .build() | |
| 175 | + | |
| 160 | 176 | private fun findCalendarId(displayName: String): Long? { |
| 161 | 177 | val projection = arrayOf(CalendarContract.Calendars._ID) |
| 162 | 178 | val selection = "${CalendarContract.Calendars.ACCOUNT_NAME} = ? AND " + |
M
android/app/src/main/java/fr/ebii/card2vcf/sync/SyncEngine.kt
+127
-60
@@ -18,11 +18,26 @@ data class SyncStatusResult(
| 18 | 18 | val error: String? = null, |
| 19 | 19 | ) |
| 20 | 20 | |
| 21 | +/** | |
| 22 | + * Op refusée par le serveur et laissée en file. [message] est le message serveur verbatim | |
| 23 | + * (« Réservation refusée : période en conflit avec Entretien »), `null` si l'op n'a même pas | |
| 24 | + * atteint le réseau (référence locale non résolue) ; [code] vaut -1 pour une panne réseau. | |
| 25 | + */ | |
| 26 | +data class PushFailure( | |
| 27 | + val entityType: String, | |
| 28 | + val op: String, | |
| 29 | + val code: Int? = null, | |
| 30 | + val message: String? = null, | |
| 31 | +) | |
| 32 | + | |
| 21 | 33 | data class SyncResult( |
| 22 | 34 | val success: Boolean, |
| 23 | 35 | val serverTime: String? = null, |
| 24 | 36 | val error: String? = null, |
| 25 | 37 | val conflicts: List<AgendaConflict> = emptyList(), |
| 38 | + val pushed: Int = 0, | |
| 39 | + val received: Int = 0, | |
| 40 | + val pushFailures: List<PushFailure> = emptyList(), | |
| 26 | 41 | ) |
| 27 | 42 | |
| 28 | 43 | /** |
@@ -57,17 +72,30 @@ class SyncEngine(
| 57 | 72 | val agendaEnabled = bridge != null && bindings.isNotEmpty() |
| 58 | 73 | if (agendaEnabled) agenda.agendaToRoom(bindings, bridge!!) |
| 59 | 74 | |
| 60 | - val conflicts = pushOps() | |
| 75 | + val push = pushOps() | |
| 61 | 76 | val ressourcesQuery = if (agendaEnabled) agenda.ressourcesQuery(bindings) else null |
| 62 | 77 | |
| 63 | 78 | when (val result = api.syncPull(watermark(), ressourcesQuery)) { |
| 64 | 79 | is AilianceApiClient.ApiResult.Ok -> { |
| 65 | - applyPull(result.value) | |
| 80 | + val received = applyPull(result.value) | |
| 66 | 81 | if (agendaEnabled) agenda.roomToAgenda(bindings, bridge!!) |
| 67 | 82 | setWatermark(result.value.serverTime) |
| 68 | - SyncResult(success = true, serverTime = result.value.serverTime, conflicts = conflicts) | |
| 83 | + SyncResult( | |
| 84 | + success = true, | |
| 85 | + serverTime = result.value.serverTime, | |
| 86 | + conflicts = push.conflicts, | |
| 87 | + pushed = push.succeeded, | |
| 88 | + received = received, | |
| 89 | + pushFailures = push.failures, | |
| 90 | + ) | |
| 69 | 91 | } |
| 70 | - is AilianceApiClient.ApiResult.Err -> SyncResult(success = false, error = result.message, conflicts = conflicts) | |
| 92 | + is AilianceApiClient.ApiResult.Err -> SyncResult( | |
| 93 | + success = false, | |
| 94 | + error = result.message, | |
| 95 | + conflicts = push.conflicts, | |
| 96 | + pushed = push.succeeded, | |
| 97 | + pushFailures = push.failures, | |
| 98 | + ) | |
| 71 | 99 | } |
| 72 | 100 | } |
| 73 | 101 |
@@ -108,30 +136,56 @@ class SyncEngine(
| 108 | 136 | |
| 109 | 137 | // ---- push ---- |
| 110 | 138 | |
| 111 | - /** Résultat d'une op poussée : [conflict] non nul uniquement pour un 409 réservation ([ok] reste `false`). */ | |
| 112 | - private data class PushOutcome(val ok: Boolean, val conflict: AgendaConflict? = null) | |
| 113 | - | |
| 114 | - private suspend fun pushOps(): List<AgendaConflict> { | |
| 139 | + /** | |
| 140 | + * Résultat d'une op poussée. [conflict] non nul uniquement pour un 409 réservation ([ok] reste | |
| 141 | + * `false`) : c'est un cas arbitré par la dialog dédiée, pas un échec générique. [code]/[message] | |
| 142 | + * portent la réponse serveur pour tous les autres refus, afin de les remonter à l'utilisateur. | |
| 143 | + */ | |
| 144 | + private data class PushOutcome( | |
| 145 | + val ok: Boolean, | |
| 146 | + val conflict: AgendaConflict? = null, | |
| 147 | + val code: Int? = null, | |
| 148 | + val message: String? = null, | |
| 149 | + ) | |
| 150 | + | |
| 151 | + /** Récapitulatif d'un cycle de push : ops tentées / dépilées, conflits 409 et échecs génériques. */ | |
| 152 | + private data class PushReport( | |
| 153 | + val attempted: Int, | |
| 154 | + val succeeded: Int, | |
| 155 | + val conflicts: List<AgendaConflict>, | |
| 156 | + val failures: List<PushFailure>, | |
| 157 | + ) | |
| 158 | + | |
| 159 | + private suspend fun pushOps(): PushReport { | |
| 115 | 160 | val conflicts = mutableListOf<AgendaConflict>() |
| 161 | + val failures = mutableListOf<PushFailure>() | |
| 162 | + var attempted = 0 | |
| 163 | + var succeeded = 0 | |
| 116 | 164 | for (op in db.syncOpDao().listAll()) { |
| 165 | + attempted++ | |
| 117 | 166 | val outcome = when (op.entityType) { |
| 118 | - "contact" -> PushOutcome(pushContactOp(op)) | |
| 119 | - ENTITY_CONTACT_MEDIA -> PushOutcome(pushContactMediaOp(op)) | |
| 120 | - "entreprise" -> PushOutcome(pushEntrepriseOp(op)) | |
| 121 | - "projet" -> PushOutcome(pushProjetOp(op)) | |
| 122 | - "tache" -> PushOutcome(pushTacheOp(op)) | |
| 123 | - "interaction" -> PushOutcome(pushInteractionOp(op)) | |
| 124 | - AgendaSyncCoordinator.KIND_RDV -> PushOutcome(pushRdvOp(op)) | |
| 167 | + "contact" -> pushContactOp(op) | |
| 168 | + ENTITY_CONTACT_MEDIA -> pushContactMediaOp(op) | |
| 169 | + "entreprise" -> pushEntrepriseOp(op) | |
| 170 | + "projet" -> pushProjetOp(op) | |
| 171 | + "tache" -> pushTacheOp(op) | |
| 172 | + "interaction" -> pushInteractionOp(op) | |
| 173 | + AgendaSyncCoordinator.KIND_RDV -> pushRdvOp(op) | |
| 125 | 174 | AgendaSyncCoordinator.ENTITY_RESERVATION -> pushReservationOp(op) |
| 126 | 175 | else -> PushOutcome(true) |
| 127 | 176 | } |
| 128 | - if (outcome.ok) db.syncOpDao().deleteById(op.id) | |
| 177 | + if (outcome.ok) { | |
| 178 | + succeeded++ | |
| 179 | + db.syncOpDao().deleteById(op.id) | |
| 180 | + } else if (outcome.conflict == null) { | |
| 181 | + failures += PushFailure(op.entityType, op.op, outcome.code, outcome.message) | |
| 182 | + } | |
| 129 | 183 | outcome.conflict?.let(conflicts::add) |
| 130 | 184 | } |
| 131 | - return conflicts | |
| 185 | + return PushReport(attempted, succeeded, conflicts, failures) | |
| 132 | 186 | } |
| 133 | 187 | |
| 134 | - private suspend fun pushContactOp(op: SyncOpEntity): Boolean { | |
| 188 | + private suspend fun pushContactOp(op: SyncOpEntity): PushOutcome { | |
| 135 | 189 | return when (op.op) { |
| 136 | 190 | "create" -> { |
| 137 | 191 | val result = api.createContact(op.payloadJson) |
@@ -146,26 +200,27 @@ class SyncEngine(
| 146 | 200 | } |
| 147 | 201 | } |
| 148 | 202 | } |
| 149 | - result is AilianceApiClient.ApiResult.Ok | |
| 203 | + result.toOutcome() | |
| 150 | 204 | } |
| 151 | 205 | "update" -> { |
| 152 | - val serverId = op.serverId ?: return false | |
| 153 | - if (!api.updateContact(serverId, op.payloadJson).isOk()) return false | |
| 206 | + val serverId = op.serverId ?: return PushOutcome(false) | |
| 207 | + val result = api.updateContact(serverId, op.payloadJson) | |
| 208 | + if (!result.isOk()) return result.toOutcome() | |
| 154 | 209 | val localId = op.localId ?: db.crmContactDao().getByServerId(serverId)?.id |
| 155 | 210 | if (localId != null && !pushContactImages(localId, serverId)) { |
| 156 | 211 | enqueueContactMediaRetry(localId, serverId) |
| 157 | 212 | } |
| 158 | - true | |
| 213 | + PushOutcome(true) | |
| 159 | 214 | } |
| 160 | - "delete" -> op.serverId?.let { api.deleteContact(it).isOk() } ?: false | |
| 161 | - else -> true | |
| 215 | + "delete" -> op.serverId?.let { api.deleteContact(it).toOutcome() } ?: PushOutcome(false) | |
| 216 | + else -> PushOutcome(true) | |
| 162 | 217 | } |
| 163 | 218 | } |
| 164 | 219 | |
| 165 | - private suspend fun pushContactMediaOp(op: SyncOpEntity): Boolean { | |
| 166 | - val serverId = op.serverId ?: return true | |
| 167 | - val localId = op.localId ?: db.crmContactDao().getByServerId(serverId)?.id ?: return true | |
| 168 | - return pushContactImages(localId, serverId) | |
| 220 | + private suspend fun pushContactMediaOp(op: SyncOpEntity): PushOutcome { | |
| 221 | + val serverId = op.serverId ?: return PushOutcome(true) | |
| 222 | + val localId = op.localId ?: db.crmContactDao().getByServerId(serverId)?.id ?: return PushOutcome(true) | |
| 223 | + return PushOutcome(pushContactImages(localId, serverId)) | |
| 169 | 224 | } |
| 170 | 225 | |
| 171 | 226 | /** |
@@ -211,7 +266,7 @@ class SyncEngine(
| 211 | 266 | ) |
| 212 | 267 | } |
| 213 | 268 | |
| 214 | - private suspend fun pushEntrepriseOp(op: SyncOpEntity): Boolean = when (op.op) { | |
| 269 | + private suspend fun pushEntrepriseOp(op: SyncOpEntity): PushOutcome = when (op.op) { | |
| 215 | 270 | "create" -> { |
| 216 | 271 | val result = api.createEntreprise(op.payloadJson) |
| 217 | 272 | if (result is AilianceApiClient.ApiResult.Ok) { |
@@ -222,14 +277,14 @@ class SyncEngine(
| 222 | 277 | } |
| 223 | 278 | } |
| 224 | 279 | } |
| 225 | - result is AilianceApiClient.ApiResult.Ok | |
| 280 | + result.toOutcome() | |
| 226 | 281 | } |
| 227 | - "update" -> op.serverId?.let { api.updateEntreprise(it, op.payloadJson).isOk() } ?: false | |
| 228 | - "delete" -> op.serverId?.let { api.deleteEntreprise(it).isOk() } ?: false | |
| 229 | - else -> true | |
| 282 | + "update" -> op.serverId?.let { api.updateEntreprise(it, op.payloadJson).toOutcome() } ?: PushOutcome(false) | |
| 283 | + "delete" -> op.serverId?.let { api.deleteEntreprise(it).toOutcome() } ?: PushOutcome(false) | |
| 284 | + else -> PushOutcome(true) | |
| 230 | 285 | } |
| 231 | 286 | |
| 232 | - private suspend fun pushProjetOp(op: SyncOpEntity): Boolean = when (op.op) { | |
| 287 | + private suspend fun pushProjetOp(op: SyncOpEntity): PushOutcome = when (op.op) { | |
| 233 | 288 | "create" -> { |
| 234 | 289 | val result = api.createProjet(op.payloadJson) |
| 235 | 290 | if (result is AilianceApiClient.ApiResult.Ok) { |
@@ -240,11 +295,11 @@ class SyncEngine(
| 240 | 295 | } |
| 241 | 296 | } |
| 242 | 297 | } |
| 243 | - result is AilianceApiClient.ApiResult.Ok | |
| 298 | + result.toOutcome() | |
| 244 | 299 | } |
| 245 | - "update" -> op.serverId?.let { api.updateProjet(it, op.payloadJson).isOk() } ?: false | |
| 246 | - "delete" -> op.serverId?.let { api.deleteProjet(it).isOk() } ?: false | |
| 247 | - else -> true | |
| 300 | + "update" -> op.serverId?.let { api.updateProjet(it, op.payloadJson).toOutcome() } ?: PushOutcome(false) | |
| 301 | + "delete" -> op.serverId?.let { api.deleteProjet(it).toOutcome() } ?: PushOutcome(false) | |
| 302 | + else -> PushOutcome(true) | |
| 248 | 303 | } |
| 249 | 304 | |
| 250 | 305 | /** Résout le `projetServerId` d'une tâche via [op.localId] (create) ou [op.serverId] (update/delete/move). */ |
@@ -254,8 +309,8 @@ class SyncEngine(
| 254 | 309 | return null |
| 255 | 310 | } |
| 256 | 311 | |
| 257 | - private suspend fun pushTacheOp(op: SyncOpEntity): Boolean { | |
| 258 | - val projetId = resolveTacheProjetId(op) ?: return false | |
| 312 | + private suspend fun pushTacheOp(op: SyncOpEntity): PushOutcome { | |
| 313 | + val projetId = resolveTacheProjetId(op) ?: return PushOutcome(false) | |
| 259 | 314 | return when (op.op) { |
| 260 | 315 | "create" -> { |
| 261 | 316 | val result = api.createTache(projetId, op.payloadJson) |
@@ -267,23 +322,23 @@ class SyncEngine(
| 267 | 322 | } |
| 268 | 323 | } |
| 269 | 324 | } |
| 270 | - result is AilianceApiClient.ApiResult.Ok | |
| 325 | + result.toOutcome() | |
| 271 | 326 | } |
| 272 | - "update" -> op.serverId?.let { api.updateTache(projetId, it, op.payloadJson).isOk() } ?: false | |
| 273 | - "delete" -> op.serverId?.let { api.deleteTache(projetId, it).isOk() } ?: false | |
| 274 | - "move" -> op.serverId?.let { api.moveTache(projetId, it, op.payloadJson).isOk() } ?: false | |
| 275 | - else -> true | |
| 327 | + "update" -> op.serverId?.let { api.updateTache(projetId, it, op.payloadJson).toOutcome() } ?: PushOutcome(false) | |
| 328 | + "delete" -> op.serverId?.let { api.deleteTache(projetId, it).toOutcome() } ?: PushOutcome(false) | |
| 329 | + "move" -> op.serverId?.let { api.moveTache(projetId, it, op.payloadJson).toOutcome() } ?: PushOutcome(false) | |
| 330 | + else -> PushOutcome(true) | |
| 276 | 331 | } |
| 277 | 332 | } |
| 278 | 333 | |
| 279 | 334 | /** `op.serverId` porte le `serverId` du contact cible (les interactions n'ont pas d'update/delete). */ |
| 280 | - private suspend fun pushInteractionOp(op: SyncOpEntity): Boolean { | |
| 281 | - if (op.op != "create") return true | |
| 282 | - val contactServerId = op.serverId ?: return false | |
| 283 | - return api.createInteraction(contactServerId, op.payloadJson).isOk() | |
| 335 | + private suspend fun pushInteractionOp(op: SyncOpEntity): PushOutcome { | |
| 336 | + if (op.op != "create") return PushOutcome(true) | |
| 337 | + val contactServerId = op.serverId ?: return PushOutcome(false) | |
| 338 | + return api.createInteraction(contactServerId, op.payloadJson).toOutcome() | |
| 284 | 339 | } |
| 285 | 340 | |
| 286 | - private suspend fun pushRdvOp(op: SyncOpEntity): Boolean = when (op.op) { | |
| 341 | + private suspend fun pushRdvOp(op: SyncOpEntity): PushOutcome = when (op.op) { | |
| 287 | 342 | "create" -> { |
| 288 | 343 | val result = api.createRdv(op.payloadJson) |
| 289 | 344 | if (result is AilianceApiClient.ApiResult.Ok) { |
@@ -294,17 +349,17 @@ class SyncEngine(
| 294 | 349 | } |
| 295 | 350 | } |
| 296 | 351 | } |
| 297 | - result is AilianceApiClient.ApiResult.Ok | |
| 352 | + result.toOutcome() | |
| 298 | 353 | } |
| 299 | 354 | "update" -> op.serverId?.let { serverId -> |
| 300 | - val ok = api.updateRdv(serverId, op.payloadJson).isOk() | |
| 301 | - if (ok && op.localId != null) { | |
| 355 | + val result = api.updateRdv(serverId, op.payloadJson) | |
| 356 | + if (result.isOk() && op.localId != null) { | |
| 302 | 357 | db.rdvDao().getByLocalId(op.localId)?.let { db.rdvDao().upsert(it.copy(dirtyLocal = false)) } |
| 303 | 358 | } |
| 304 | - ok | |
| 305 | - } ?: false | |
| 306 | - "delete" -> op.serverId?.let { api.deleteRdv(it).isOk() } ?: false | |
| 307 | - else -> true | |
| 359 | + result.toOutcome() | |
| 360 | + } ?: PushOutcome(false) | |
| 361 | + "delete" -> op.serverId?.let { api.deleteRdv(it).toOutcome() } ?: PushOutcome(false) | |
| 362 | + else -> PushOutcome(true) | |
| 308 | 363 | } |
| 309 | 364 | |
| 310 | 365 | /** |
@@ -329,7 +384,7 @@ class SyncEngine(
| 329 | 384 | "update" -> op.serverId?.let { serverId -> |
| 330 | 385 | handleReservationPushResult(api.updateReservation(cibleType, cibleId, serverId, op.payloadJson), op) |
| 331 | 386 | } ?: PushOutcome(false) |
| 332 | - "delete" -> op.serverId?.let { PushOutcome(api.deleteReservation(cibleType, cibleId, it).isOk()) } ?: PushOutcome(false) | |
| 387 | + "delete" -> op.serverId?.let { api.deleteReservation(cibleType, cibleId, it).toOutcome() } ?: PushOutcome(false) | |
| 333 | 388 | else -> PushOutcome(true) |
| 334 | 389 | } |
| 335 | 390 | } |
@@ -354,14 +409,18 @@ class SyncEngine(
| 354 | 409 | } |
| 355 | 410 | PushOutcome(false, AgendaConflict(localReservationId = op.localId, message = result.message)) |
| 356 | 411 | } else { |
| 357 | - PushOutcome(false) | |
| 412 | + result.toOutcome() | |
| 358 | 413 | } |
| 359 | 414 | } |
| 360 | 415 | } |
| 361 | 416 | |
| 362 | 417 | // ---- pull ---- |
| 363 | 418 | |
| 364 | - private suspend fun applyPull(pull: SyncPullResponse) { | |
| 419 | + /** | |
| 420 | + * Applique le pull et renvoie le nombre d'éléments reçus tel qu'un utilisateur le compte : | |
| 421 | + * workflows (référentiel) et tombstones (suppressions) sont exclus. | |
| 422 | + */ | |
| 423 | + private suspend fun applyPull(pull: SyncPullResponse): Int { | |
| 365 | 424 | pull.contacts.forEach { applyContact(it) } |
| 366 | 425 | pull.entreprises.forEach { applyEntreprise(it) } |
| 367 | 426 | pull.projets.forEach { applyProjet(it) } |
@@ -372,6 +431,8 @@ class SyncEngine(
| 372 | 431 | pull.reservations.forEach { agenda.applyReservationPull(it) } |
| 373 | 432 | pull.indisponibilites.forEach { agenda.applyIndisponibilitePull(it) } |
| 374 | 433 | pull.tombstones.forEach { applyTombstone(it) } |
| 434 | + return pull.contacts.size + pull.entreprises.size + pull.projets.size + pull.taches.size + | |
| 435 | + pull.interactions.size + pull.rdv.size + pull.reservations.size + pull.indisponibilites.size | |
| 375 | 436 | } |
| 376 | 437 | |
| 377 | 438 | private suspend fun applyContact(dto: ContactDto) { |
@@ -607,6 +668,12 @@ class SyncEngine(
| 607 | 668 | |
| 608 | 669 | fun <T> AilianceApiClient.ApiResult<T>.isOk(): Boolean = this is AilianceApiClient.ApiResult.Ok |
| 609 | 670 | |
| 671 | + /** Conserve `code`/`message` serveur sur refus, pour que [pushOps] puisse les remonter à l'UI. */ | |
| 672 | + fun <T> AilianceApiClient.ApiResult<T>.toOutcome(): PushOutcome = when (this) { | |
| 673 | + is AilianceApiClient.ApiResult.Ok -> PushOutcome(true) | |
| 674 | + is AilianceApiClient.ApiResult.Err -> PushOutcome(false, code = code, message = message) | |
| 675 | + } | |
| 676 | + | |
| 610 | 677 | @Serializable |
| 611 | 678 | data class CreatedIdDto(val id: String? = null) |
| 612 | 679 |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/carnet/CarnetScreen.kt
+2
-0
@@ -97,6 +97,7 @@ fun CarnetScreen(
| 97 | 97 | val syncing by syncViewModel.syncing.collectAsState() |
| 98 | 98 | val syncError by syncViewModel.error.collectAsState() |
| 99 | 99 | val syncConflict by syncViewModel.conflictPending.collectAsState() |
| 100 | + val syncSummary by syncViewModel.syncSummary.collectAsState() | |
| 100 | 101 | |
| 101 | 102 | LaunchedEffect(Unit) { |
| 102 | 103 | syncViewModel.refreshStatus() |
@@ -249,6 +250,7 @@ fun CarnetScreen(
| 249 | 250 | syncing = syncing, |
| 250 | 251 | error = syncError, |
| 251 | 252 | onSyncClick = { syncViewModel.syncNow() }, |
| 253 | + summary = syncSummary, | |
| 252 | 254 | ) |
| 253 | 255 | |
| 254 | 256 | SyncConflictDialog( |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/projets/ProjetsListScreen.kt
+2
-0
@@ -57,6 +57,7 @@ fun ProjetsListScreen(
| 57 | 57 | val syncing by syncViewModel.syncing.collectAsState() |
| 58 | 58 | val syncError by syncViewModel.error.collectAsState() |
| 59 | 59 | val syncConflict by syncViewModel.conflictPending.collectAsState() |
| 60 | + val syncSummary by syncViewModel.syncSummary.collectAsState() | |
| 60 | 61 | |
| 61 | 62 | LaunchedEffect(Unit) { |
| 62 | 63 | syncViewModel.refreshStatus() |
@@ -97,6 +98,7 @@ fun ProjetsListScreen(
| 97 | 98 | syncing = syncing, |
| 98 | 99 | error = syncError, |
| 99 | 100 | onSyncClick = { syncViewModel.syncNow() }, |
| 101 | + summary = syncSummary, | |
| 100 | 102 | ) |
| 101 | 103 | |
| 102 | 104 | SyncConflictDialog( |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/settings/SettingsViewModel.kt
+32
-10
@@ -246,22 +246,40 @@ class SettingsViewModel(
| 246 | 246 | } |
| 247 | 247 | |
| 248 | 248 | /** |
| 249 | - * Confirme le retrait d'une liaison (« Oui » du dialog de confirmation). Se limite à la liaison | |
| 250 | - * ([CalendarBindingsStore.remove]) : [CalendarBridgeApi] n'expose pas de suppression de calendrier | |
| 251 | - * Android, le calendrier local créé reste donc visible (vide) dans l'app Agenda système. | |
| 249 | + * Confirme le retrait d'une liaison (« Oui » du dialog de confirmation) : supprime le calendrier | |
| 250 | + * local de l'appareil ([CalendarBridgeApi.deleteCalendar], événements en cascade) **puis** retire | |
| 251 | + * la liaison ([CalendarBindingsStore.remove]). Les données serveur (RDV, réservations) ne sont pas | |
| 252 | + * touchées. Si la suppression échoue (permission Agenda révoquée entre-temps), la liaison est | |
| 253 | + * retirée quand même et l'utilisateur en est informé via [SettingsUiState.LoggedIn.catalogueError]. | |
| 252 | 254 | */ |
| 253 | 255 | fun confirmRemoveBinding() { |
| 254 | 256 | val s = _state.value |
| 255 | 257 | if (s !is SettingsUiState.LoggedIn) return |
| 256 | 258 | val pending = s.pendingRemoval ?: return |
| 257 | - calendarBindingsStore.remove(pending.kind, pending.serverResourceId) | |
| 258 | - updateLoggedIn { current -> | |
| 259 | - val unlinked = if (pending.kind == AgendaSyncCoordinator.KIND_RDV) { | |
| 260 | - current.copy(mesRdvLinked = false) | |
| 261 | - } else { | |
| 262 | - current.copy(ressources = current.ressources.map { it.withLinked(pending.kind, pending.serverResourceId, false) }) | |
| 259 | + val calendarId = calendarBindingsStore.list() | |
| 260 | + .firstOrNull { it.kind == pending.kind && it.serverResourceId == pending.serverResourceId } | |
| 261 | + ?.androidCalendarId | |
| 262 | + viewModelScope.launch { | |
| 263 | + val deleted = withContext(ioDispatcher) { | |
| 264 | + try { | |
| 265 | + if (calendarId != null) calendarBridge.deleteCalendar(calendarId) | |
| 266 | + true | |
| 267 | + } catch (_: Exception) { | |
| 268 | + false | |
| 269 | + } | |
| 270 | + } | |
| 271 | + calendarBindingsStore.remove(pending.kind, pending.serverResourceId) | |
| 272 | + updateLoggedIn { current -> | |
| 273 | + val unlinked = if (pending.kind == AgendaSyncCoordinator.KIND_RDV) { | |
| 274 | + current.copy(mesRdvLinked = false) | |
| 275 | + } else { | |
| 276 | + current.copy(ressources = current.ressources.map { it.withLinked(pending.kind, pending.serverResourceId, false) }) | |
| 277 | + } | |
| 278 | + unlinked.copy( | |
| 279 | + pendingRemoval = null, | |
| 280 | + catalogueError = if (deleted) current.catalogueError else CALENDAR_DELETE_ERROR, | |
| 281 | + ) | |
| 263 | 282 | } |
| 264 | - unlinked.copy(pendingRemoval = null) | |
| 265 | 283 | } |
| 266 | 284 | } |
| 267 | 285 |
@@ -329,5 +347,9 @@ class SettingsViewModel(
| 329 | 347 | const val KIND_MATERIEL = "materiel" |
| 330 | 348 | const val KIND_VEHICULE = "vehicule" |
| 331 | 349 | const val MES_RDV_DISPLAY_NAME = "PicLead — Mes RDV" |
| 350 | + | |
| 351 | + /** Affiché quand la suppression du calendrier échoue (permission Agenda révoquée entre-temps). */ | |
| 352 | + const val CALENDAR_DELETE_ERROR = | |
| 353 | + "Calendrier non supprimé : autorisation Agenda manquante. La liaison a bien été retirée." | |
| 332 | 354 | } |
| 333 | 355 | } |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/sync/SyncBanner.kt
+21
-3
@@ -24,8 +24,9 @@ import fr.ebii.card2vcf.ui.theme.Ink
| 24 | 24 | private val Square = RoundedCornerShape(0.dp) |
| 25 | 25 | |
| 26 | 26 | /** |
| 27 | - * Bandeaux hairline empilés : A) changements sur le serveur (ou erreur) ; B) calendrier local en | |
| 28 | - * avance ([localAhead] > 0, events/entités pas encore poussés). Masqués si rien à signaler. | |
| 27 | + * Bandeaux hairline empilés : A) résumé de la dernière synchro ([summary], avec le message serveur | |
| 28 | + * verbatim si des ops ont été refusées) ; B) changements sur le serveur (ou erreur) ; C) calendrier | |
| 29 | + * local en avance ([localAhead] > 0, events/entités pas encore poussés). Masqués si rien à signaler. | |
| 29 | 30 | */ |
| 30 | 31 | @Composable |
| 31 | 32 | fun SyncBanner( |
@@ -35,15 +36,32 @@ fun SyncBanner(
| 35 | 36 | error: String?, |
| 36 | 37 | onSyncClick: () -> Unit, |
| 37 | 38 | modifier: Modifier = Modifier, |
| 39 | + summary: SyncSummary? = null, | |
| 38 | 40 | ) { |
| 41 | + val summaryLabel = summary?.let { | |
| 42 | + if (it.failures > 0) { | |
| 43 | + stringResource( | |
| 44 | + R.string.sync_resume_avec_echecs, | |
| 45 | + it.pushed, | |
| 46 | + it.received, | |
| 47 | + it.failures, | |
| 48 | + it.firstFailureMessage ?: stringResource(R.string.sync_resume_echec_sans_message), | |
| 49 | + ) | |
| 50 | + } else { | |
| 51 | + stringResource(R.string.sync_resume, it.pushed, it.received) | |
| 52 | + } | |
| 53 | + } | |
| 39 | 54 | val remoteLabel = when { |
| 40 | 55 | error != null -> error |
| 41 | 56 | pendingChanges != null && pendingChanges > 0 -> stringResource(R.string.sync_pending, pendingChanges) |
| 42 | 57 | else -> null |
| 43 | 58 | } |
| 44 | - if (remoteLabel == null && localAhead <= 0) return | |
| 59 | + if (summaryLabel == null && remoteLabel == null && localAhead <= 0) return | |
| 45 | 60 | |
| 46 | 61 | Column(modifier.fillMaxWidth()) { |
| 62 | + if (summaryLabel != null) { | |
| 63 | + SyncBannerRow(label = summaryLabel, syncing = syncing, onSyncClick = onSyncClick) | |
| 64 | + } | |
| 47 | 65 | if (remoteLabel != null) { |
| 48 | 66 | SyncBannerRow(label = remoteLabel, syncing = syncing, onSyncClick = onSyncClick) |
| 49 | 67 | } |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/sync/SyncChromeViewModel.kt
+25
-1
@@ -14,9 +14,22 @@ import kotlinx.coroutines.flow.asStateFlow
| 14 | 14 | import kotlinx.coroutines.launch |
| 15 | 15 | |
| 16 | 16 | /** |
| 17 | + * Résumé de la dernière synchro, rendu par `SyncBanner` (les libellés vivent dans les ressources). | |
| 18 | + * [firstFailureMessage] est le message serveur verbatim du premier refus, `null` si le serveur n'en | |
| 19 | + * a pas fourni. | |
| 20 | + */ | |
| 21 | +data class SyncSummary( | |
| 22 | + val pushed: Int, | |
| 23 | + val received: Int, | |
| 24 | + val failures: Int, | |
| 25 | + val firstFailureMessage: String? = null, | |
| 26 | +) | |
| 27 | + | |
| 28 | +/** | |
| 17 | 29 | * État bandeau sync : changements serveur + calendrier local en avance à l'ouverture, sync manuelle |
| 18 | 30 | * à la demande. Après [syncNow], une réservation en conflit (409) déclenche le dialog |
| 19 | - * « Annuler ma réservation ? » ([conflictPending]) — une décision à la fois si plusieurs conflits. | |
| 31 | + * « Annuler ma réservation ? » ([conflictPending]) — une décision à la fois si plusieurs conflits ; | |
| 32 | + * tout autre refus de push est reporté dans [syncSummary] avec le message du serveur. | |
| 20 | 33 | */ |
| 21 | 34 | class SyncChromeViewModel( |
| 22 | 35 | private val credentialsStore: SyncCredentialsStore, |
@@ -40,6 +53,10 @@ class SyncChromeViewModel(
| 40 | 53 | private val _error = MutableStateFlow<String?>(null) |
| 41 | 54 | val error: StateFlow<String?> = _error.asStateFlow() |
| 42 | 55 | |
| 56 | + /** Résumé de la dernière synchro réussie ; remis à zéro au début de chaque [syncNow]. */ | |
| 57 | + private val _syncSummary = MutableStateFlow<SyncSummary?>(null) | |
| 58 | + val syncSummary: StateFlow<SyncSummary?> = _syncSummary.asStateFlow() | |
| 59 | + | |
| 43 | 60 | private val pendingConflicts = ArrayDeque<AgendaConflict>() |
| 44 | 61 | private val _conflictPending = MutableStateFlow<AgendaConflict?>(null) |
| 45 | 62 | val conflictPending: StateFlow<AgendaConflict?> = _conflictPending.asStateFlow() |
@@ -62,10 +79,17 @@ class SyncChromeViewModel(
| 62 | 79 | viewModelScope.launch { |
| 63 | 80 | _syncing.value = true |
| 64 | 81 | _error.value = null |
| 82 | + _syncSummary.value = null | |
| 65 | 83 | val bindings = calendarBindingsStore.list() |
| 66 | 84 | val result = engine.syncNow(bindings, calendarBridge) |
| 67 | 85 | if (result.success) { |
| 68 | 86 | setPendingConflicts(result.conflicts) |
| 87 | + _syncSummary.value = SyncSummary( | |
| 88 | + pushed = result.pushed, | |
| 89 | + received = result.received, | |
| 90 | + failures = result.pushFailures.size, | |
| 91 | + firstFailureMessage = result.pushFailures.firstOrNull()?.message?.takeIf { it.isNotBlank() }, | |
| 92 | + ) | |
| 69 | 93 | val status = engine.checkStatus(AgendaSyncCoordinator.ressourcesQuery(bindings)) |
| 70 | 94 | _pendingRemoteChanges.value = status.pendingRemoteChanges |
| 71 | 95 | _error.value = status.error |
M
android/app/src/main/res/values-en/strings.xml
+4
-1
@@ -107,7 +107,7 @@
| 107 | 107 | <string name="settings_calendrier_chargement">Loading resources…</string> |
| 108 | 108 | <string name="settings_calendrier_ressource_inactive">inactive</string> |
| 109 | 109 | <string name="settings_calendrier_retirer_titre">Remove this calendar?</string> |
| 110 | - <string name="settings_calendrier_retirer_message">"%1$s" will no longer sync. The calendar stays visible in the Calendar app.</string> | |
| 110 | + <string name="settings_calendrier_retirer_message">"%1$s" and its events will be deleted from this device. Your appointments and bookings stay on the PicLead server.</string> | |
| 111 | 111 | <string name="settings_calendrier_retirer_confirmer">Remove</string> |
| 112 | 112 | <string name="settings_calendrier_retirer_annuler">Cancel</string> |
| 113 | 113 |
@@ -116,6 +116,9 @@
| 116 | 116 | <string name="sync_en_cours">Syncing…</string> |
| 117 | 117 | <string name="sync_icone">Sync</string> |
| 118 | 118 | <string name="sync_local_en_avance">Local calendar is ahead of the server</string> |
| 119 | + <string name="sync_resume">Sync: %1$d sent · %2$d received</string> | |
| 120 | + <string name="sync_resume_avec_echecs">Sync: %1$d sent · %2$d received · %3$d failed: %4$s</string> | |
| 121 | + <string name="sync_resume_echec_sans_message">server refused without details</string> | |
| 119 | 122 | <string name="sync_conflit_titre">Cancel my reservation?</string> |
| 120 | 123 | <string name="sync_conflit_message">This reservation conflicts with another one on the server. Do you want to cancel it?</string> |
| 121 | 124 | <string name="sync_conflit_oui">Yes, cancel</string> |
M
android/app/src/main/res/values/strings.xml
+4
-1
@@ -107,7 +107,7 @@
| 107 | 107 | <string name="settings_calendrier_chargement">Chargement des ressources…</string> |
| 108 | 108 | <string name="settings_calendrier_ressource_inactive">inactive</string> |
| 109 | 109 | <string name="settings_calendrier_retirer_titre">Retirer ce calendrier ?</string> |
| 110 | - <string name="settings_calendrier_retirer_message">« %1$s » ne sera plus synchronisé. Le calendrier reste visible dans l\'app Agenda.</string> | |
| 110 | + <string name="settings_calendrier_retirer_message">« %1$s » et ses événements seront supprimés de l\'appareil. Vos RDV et réservations restent sur le serveur PicLead.</string> | |
| 111 | 111 | <string name="settings_calendrier_retirer_confirmer">Retirer</string> |
| 112 | 112 | <string name="settings_calendrier_retirer_annuler">Annuler</string> |
| 113 | 113 |
@@ -116,6 +116,9 @@
| 116 | 116 | <string name="sync_en_cours">Synchronisation…</string> |
| 117 | 117 | <string name="sync_icone">Synchroniser</string> |
| 118 | 118 | <string name="sync_local_en_avance">Calendrier local en avance sur le serveur</string> |
| 119 | + <string name="sync_resume">Synchro : %1$d envoyé(s) · %2$d reçu(s)</string> | |
| 120 | + <string name="sync_resume_avec_echecs">Synchro : %1$d envoyé(s) · %2$d reçu(s) · %3$d échec(s) : %4$s</string> | |
| 121 | + <string name="sync_resume_echec_sans_message">refus du serveur sans détail</string> | |
| 119 | 122 | <string name="sync_conflit_titre">Annuler ma réservation ?</string> |
| 120 | 123 | <string name="sync_conflit_message">Cette réservation est en conflit avec une autre sur le serveur. Voulez-vous l\'annuler ?</string> |
| 121 | 124 | <string name="sync_conflit_oui">Oui, annuler</string> |
M
android/app/src/test/java/fr/ebii/card2vcf/sync/AgendaSyncEngineTest.kt
+26
-0
@@ -125,12 +125,38 @@ class AgendaSyncEngineTest {
| 125 | 125 | |
| 126 | 126 | assertTrue(result.conflicts.isNotEmpty()) |
| 127 | 127 | assertEquals(localId, result.conflicts.single().localReservationId) |
| 128 | + // Le 409 réservation reste arbitré par la dialog dédiée : pas d'échec générique dans le résumé. | |
| 129 | + assertTrue(result.pushFailures.isEmpty()) | |
| 130 | + assertEquals(0, result.pushed) | |
| 128 | 131 | assertTrue(db.reservationDao().getByLocalId(localId)?.conflictPending == true) |
| 129 | 132 | assertEquals(1, db.syncOpDao().listAll().size) |
| 130 | 133 | assertEquals("salle", api.createReservationCalls.single().first) |
| 131 | 134 | assertEquals("salle-1", api.createReservationCalls.single().second) |
| 132 | 135 | } |
| 133 | 136 | |
| 137 | + // ---- Motif d'une réservation créée dans l'app Agenda ---- | |
| 138 | + | |
| 139 | + @Test | |
| 140 | + fun agendaEventCree_pousseLeTitreCommeMotif() = runBlocking { | |
| 141 | + val bridge = FakeCalendarBridge() | |
| 142 | + val calendarId = bridge.ensureLocalCalendar("PicLead — Véhicule kangoo") | |
| 143 | + bridge.seedEvent( | |
| 144 | + calendarId, | |
| 145 | + // Cas réel : l'app Agenda remplit le titre, pas la description. | |
| 146 | + CalendarEventSnapshot(title = "Course fournisseur", debutMs = 1_000L, finMs = 2_000L), | |
| 147 | + ) | |
| 148 | + val bindings = listOf( | |
| 149 | + CalendarBinding(kind = "vehicule", serverResourceId = "v1", displayName = "Kangoo", androidCalendarId = calendarId), | |
| 150 | + ) | |
| 151 | + | |
| 152 | + engine.syncNow(bindings, bridge) | |
| 153 | + | |
| 154 | + val entity = db.reservationDao().listByCible("vehicule", "v1").single() | |
| 155 | + assertEquals("Course fournisseur", entity.motif) | |
| 156 | + val corps = api.createReservationCalls.single().third | |
| 157 | + assertTrue("motif attendu dans $corps", corps.contains("\"motif\":\"Course fournisseur\"")) | |
| 158 | + } | |
| 159 | + | |
| 134 | 160 | // ---- Abandon ---- |
| 135 | 161 | |
| 136 | 162 | @Test |
M
android/app/src/test/java/fr/ebii/card2vcf/sync/CalendarBridgeTest.kt
+37
-2
@@ -23,7 +23,7 @@ import org.robolectric.annotation.Config
| 23 | 23 | |
| 24 | 24 | /** |
| 25 | 25 | * Couvre [CalendarBridge] au-delà des fonctions pures déjà testées dans |
| 26 | - * `CalendarServerIdCodecTest` : `ensureLocalCalendar`/`listEvents`/`upsertEvent`/`deleteEvent` | |
| 26 | + * `CalendarServerIdCodecTest` : `ensureLocalCalendar`/`deleteCalendar`/`listEvents`/`upsertEvent`/`deleteEvent` | |
| 27 | 27 | * via un `ContentProvider` en mémoire ([FakeCalendarProvider]), le vrai fournisseur Agenda |
| 28 | 28 | * n'étant pas embarqué dans `android-all` sous Robolectric. |
| 29 | 29 | */ |
@@ -118,6 +118,30 @@ class CalendarBridgeTest {
| 118 | 118 | |
| 119 | 119 | assertTrue(bridge.listEvents(calendarId).isEmpty()) |
| 120 | 120 | } |
| 121 | + | |
| 122 | + @Test | |
| 123 | + fun deleteCalendarRemovesCalendarAndItsEvents() { | |
| 124 | + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV") | |
| 125 | + bridge.upsertEvent( | |
| 126 | + calendarId, | |
| 127 | + CalendarEventSnapshot(title = "RDV client", debutMs = 1_000L, finMs = 2_000L, serverId = "rdv-42"), | |
| 128 | + ) | |
| 129 | + | |
| 130 | + bridge.deleteCalendar(calendarId) | |
| 131 | + | |
| 132 | + assertTrue(bridge.listEvents(calendarId).isEmpty()) | |
| 133 | + // Le calendrier n'existe plus : `ensureLocalCalendar` en recrée un (id différent). | |
| 134 | + assertTrue(bridge.ensureLocalCalendar("Card2vcf — Mes RDV") != calendarId) | |
| 135 | + } | |
| 136 | + | |
| 137 | + @Test | |
| 138 | + fun deleteCalendarWithUnknownIdIsNoOp() { | |
| 139 | + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV") | |
| 140 | + | |
| 141 | + bridge.deleteCalendar(9_999L) | |
| 142 | + | |
| 143 | + assertEquals(calendarId, bridge.ensureLocalCalendar("Card2vcf — Mes RDV")) | |
| 144 | + } | |
| 121 | 145 | } |
| 122 | 146 | |
| 123 | 147 | /** |
@@ -174,7 +198,18 @@ class FakeCalendarProvider : ContentProvider() {
| 174 | 198 | |
| 175 | 199 | override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int { |
| 176 | 200 | val id = ContentUris.parseId(uri) |
| 177 | - return if (events.remove(id) != null) 1 else 0 | |
| 201 | + return when (matcher.match(uri)) { | |
| 202 | + // Le vrai fournisseur Agenda supprime les events du calendrier en cascade. | |
| 203 | + CALENDARS -> { | |
| 204 | + if (calendars.remove(id) == null) return 0 | |
| 205 | + events.filterValues { it.getAsLong(CalendarContract.Events.CALENDAR_ID) == id } | |
| 206 | + .keys | |
| 207 | + .forEach { events.remove(it) } | |
| 208 | + 1 | |
| 209 | + } | |
| 210 | + EVENTS -> if (events.remove(id) != null) 1 else 0 | |
| 211 | + else -> error("Uri non supportée par FakeCalendarProvider : $uri") | |
| 212 | + } | |
| 178 | 213 | } |
| 179 | 214 | |
| 180 | 215 | private fun baseUriFor(matchCode: Int): Uri = when (matchCode) { |
M
android/app/src/test/java/fr/ebii/card2vcf/sync/FakeCalendarBridge.kt
+11
-0
@@ -7,10 +7,21 @@ class FakeCalendarBridge : CalendarBridgeApi {
| 7 | 7 | private var nextCalendarId = 1L |
| 8 | 8 | private var nextEventId = 1L |
| 9 | 9 | val deletedEventIds = mutableListOf<Long>() |
| 10 | + val deletedCalendarIds = mutableListOf<Long>() | |
| 11 | + | |
| 12 | + /** Simule une permission Agenda révoquée : [deleteCalendar] lève cette exception si non nulle. */ | |
| 13 | + var deleteCalendarFailure: Exception? = null | |
| 10 | 14 | |
| 11 | 15 | override fun ensureLocalCalendar(displayName: String): Long = |
| 12 | 16 | calendars.getOrPut(displayName) { nextCalendarId++ } |
| 13 | 17 | |
| 18 | + override fun deleteCalendar(calendarId: Long) { | |
| 19 | + deleteCalendarFailure?.let { throw it } | |
| 20 | + deletedCalendarIds += calendarId | |
| 21 | + calendars.entries.removeAll { it.value == calendarId } | |
| 22 | + events.remove(calendarId) | |
| 23 | + } | |
| 24 | + | |
| 14 | 25 | override fun listEvents(calendarId: Long): List<CalendarEventSnapshot> = |
| 15 | 26 | events[calendarId]?.values?.toList() ?: emptyList() |
| 16 | 27 |
M
android/app/src/test/java/fr/ebii/card2vcf/sync/SyncEngineTest.kt
+68
-0
@@ -5,6 +5,7 @@ import androidx.room.Room
| 5 | 5 | import androidx.test.core.app.ApplicationProvider |
| 6 | 6 | import fr.ebii.card2vcf.data.CrmContactEntity |
| 7 | 7 | import fr.ebii.card2vcf.data.CrmDatabase |
| 8 | +import fr.ebii.card2vcf.data.RdvEntity | |
| 8 | 9 | import kotlinx.coroutines.runBlocking |
| 9 | 10 | import org.junit.After |
| 10 | 11 | import org.junit.Assert.assertEquals |
@@ -261,4 +262,71 @@ class SyncEngineTest {
| 261 | 262 | assertEquals(0, api.uploadCarteCalls.size) |
| 262 | 263 | assertEquals(0, api.uploadPhotoCalls.size) |
| 263 | 264 | } |
| 265 | + | |
| 266 | + // ---- Retour utilisateur : compteurs et échecs de push ---- | |
| 267 | + | |
| 268 | + @Test | |
| 269 | + fun syncNow_pushRefusedKeepsOpAndReportsServerMessage() = runBlocking { | |
| 270 | + val localId = db.rdvDao().upsert(RdvEntity(titre = "Point client", dirtyLocal = true)) | |
| 271 | + db.syncOpDao().insert( | |
| 272 | + SyncOpEntity( | |
| 273 | + entityType = AgendaSyncCoordinator.KIND_RDV, | |
| 274 | + op = "create", | |
| 275 | + payloadJson = """{"titre":"Point client"}""", | |
| 276 | + localId = localId, | |
| 277 | + createdAt = 1L, | |
| 278 | + ), | |
| 279 | + ) | |
| 280 | + api.createRdvResult = AilianceApiClient.ApiResult.Err( | |
| 281 | + 409, | |
| 282 | + "Réservation refusée : période en conflit avec Entretien", | |
| 283 | + ) | |
| 284 | + | |
| 285 | + val result = engine.syncNow() | |
| 286 | + | |
| 287 | + assertTrue(result.success) | |
| 288 | + assertEquals(1, db.syncOpDao().listAll().size) | |
| 289 | + assertEquals(0, result.pushed) | |
| 290 | + val failure = result.pushFailures.single() | |
| 291 | + assertEquals(AgendaSyncCoordinator.KIND_RDV, failure.entityType) | |
| 292 | + assertEquals("create", failure.op) | |
| 293 | + assertEquals(409, failure.code) | |
| 294 | + assertEquals("Réservation refusée : période en conflit avec Entretien", failure.message) | |
| 295 | + } | |
| 296 | + | |
| 297 | + @Test | |
| 298 | + fun syncNow_reportsPushedAndReceivedCounts() = runBlocking { | |
| 299 | + val localId = db.crmContactDao().insert(CrmContactEntity(fullName = "Nouveau")) | |
| 300 | + db.syncOpDao().insert( | |
| 301 | + SyncOpEntity( | |
| 302 | + entityType = "contact", | |
| 303 | + op = "create", | |
| 304 | + payloadJson = """{"prenom":"A","nom":"B"}""", | |
| 305 | + localId = localId, | |
| 306 | + createdAt = 1L, | |
| 307 | + ), | |
| 308 | + ) | |
| 309 | + api.pullResult = AilianceApiClient.ApiResult.Ok( | |
| 310 | + SyncPullResponse( | |
| 311 | + serverTime = "2026-07-22T12:05:00Z", | |
| 312 | + contacts = listOf( | |
| 313 | + ContactDto(id = "c1", prenom = "Jean", nom = "Dupont", creeLe = "2026-07-20T10:00:00Z"), | |
| 314 | + ), | |
| 315 | + interactions = listOf( | |
| 316 | + InteractionDto(id = "i1", contactId = "c1", sujet = "Appel", creeLe = "2026-07-22T09:00:00Z"), | |
| 317 | + ), | |
| 318 | + // Exclus du décompte utilisateur : référentiel et suppressions. | |
| 319 | + workflows = listOf(WorkflowDto(id = "w1", nom = "Défaut", creeLe = "2026-07-20T10:00:00Z")), | |
| 320 | + tombstones = listOf( | |
| 321 | + TombstoneDto(entityType = "contact", id = "c-old", supprimeLe = "2026-07-22T12:00:00Z"), | |
| 322 | + ), | |
| 323 | + ), | |
| 324 | + ) | |
| 325 | + | |
| 326 | + val result = engine.syncNow() | |
| 327 | + | |
| 328 | + assertEquals(1, result.pushed) | |
| 329 | + assertEquals(2, result.received) | |
| 330 | + assertTrue(result.pushFailures.isEmpty()) | |
| 331 | + } | |
| 264 | 332 | } |
M
android/app/src/test/java/fr/ebii/card2vcf/ui/settings/SettingsViewModelTest.kt
+44
-1
@@ -275,17 +275,59 @@ class SettingsViewModelTest {
| 275 | 275 | } |
| 276 | 276 | |
| 277 | 277 | @Test |
| 278 | - fun confirmRemoveBinding_removesMesRdvBinding() = runTest(testDispatcher) { | |
| 278 | + fun confirmRemoveBinding_deletesLocalCalendarThenRemovesMesRdvBinding() = runTest(testDispatcher) { | |
| 279 | 279 | val vm = loggedInViewModel() |
| 280 | 280 | vm.setMesRdvEnabled(true) |
| 281 | 281 | advanceUntilIdle() |
| 282 | + val calendarId = calendarBindingsStore.list().single().androidCalendarId | |
| 282 | 283 | vm.setMesRdvEnabled(false) |
| 283 | 284 | |
| 284 | 285 | vm.confirmRemoveBinding() |
| 286 | + advanceUntilIdle() | |
| 287 | + | |
| 288 | + val state = vm.state.value as SettingsUiState.LoggedIn | |
| 289 | + assertFalse(state.mesRdvLinked) | |
| 290 | + assertNull(state.pendingRemoval) | |
| 291 | + assertNull(state.catalogueError) | |
| 292 | + assertEquals(listOf(calendarId), calendarBridge.deletedCalendarIds) | |
| 293 | + assertTrue(calendarBindingsStore.list().isEmpty()) | |
| 294 | + } | |
| 295 | + | |
| 296 | + @Test | |
| 297 | + fun confirmRemoveBinding_deletesRessourceCalendar() = runTest(testDispatcher) { | |
| 298 | + val catalogue = RessourcesCatalogueDto(vehicules = listOf(RessourceItemDto(id = "v1", nom = "Kangoo", actif = true))) | |
| 299 | + val vm = loggedInViewModel(catalogueResult = AilianceApiClient.ApiResult.Ok(catalogue)) | |
| 300 | + vm.onCalendarPermissionChanged(true) | |
| 301 | + advanceUntilIdle() | |
| 302 | + vm.setRessourceEnabled(kind = "vehicule", serverResourceId = "v1", enabled = true) | |
| 303 | + advanceUntilIdle() | |
| 304 | + val calendarId = calendarBindingsStore.list().single().androidCalendarId | |
| 305 | + vm.setRessourceEnabled(kind = "vehicule", serverResourceId = "v1", enabled = false) | |
| 306 | + | |
| 307 | + vm.confirmRemoveBinding() | |
| 308 | + advanceUntilIdle() | |
| 309 | + | |
| 310 | + val state = vm.state.value as SettingsUiState.LoggedIn | |
| 311 | + assertFalse(state.ressources.single().linked) | |
| 312 | + assertEquals(listOf(calendarId), calendarBridge.deletedCalendarIds) | |
| 313 | + assertTrue(calendarBindingsStore.list().isEmpty()) | |
| 314 | + } | |
| 315 | + | |
| 316 | + @Test | |
| 317 | + fun confirmRemoveBinding_calendarPermissionRevoked_stillUnlinksAndReportsError() = runTest(testDispatcher) { | |
| 318 | + val vm = loggedInViewModel() | |
| 319 | + vm.setMesRdvEnabled(true) | |
| 320 | + advanceUntilIdle() | |
| 321 | + vm.setMesRdvEnabled(false) | |
| 322 | + calendarBridge.deleteCalendarFailure = SecurityException("permission WRITE_CALENDAR révoquée") | |
| 323 | + | |
| 324 | + vm.confirmRemoveBinding() | |
| 325 | + advanceUntilIdle() | |
| 285 | 326 | |
| 286 | 327 | val state = vm.state.value as SettingsUiState.LoggedIn |
| 287 | 328 | assertFalse(state.mesRdvLinked) |
| 288 | 329 | assertNull(state.pendingRemoval) |
| 330 | + assertEquals(SettingsViewModel.CALENDAR_DELETE_ERROR, state.catalogueError) | |
| 289 | 331 | assertTrue(calendarBindingsStore.list().isEmpty()) |
| 290 | 332 | } |
| 291 | 333 |
@@ -302,6 +344,7 @@ class SettingsViewModelTest {
| 302 | 344 | assertTrue(state.mesRdvLinked) |
| 303 | 345 | assertNull(state.pendingRemoval) |
| 304 | 346 | assertEquals(1, calendarBindingsStore.list().size) |
| 347 | + assertTrue(calendarBridge.deletedCalendarIds.isEmpty()) | |
| 305 | 348 | } |
| 306 | 349 | |
| 307 | 350 | @Test |
M
android/app/src/test/java/fr/ebii/card2vcf/ui/sync/SyncChromeViewModelTest.kt
+67
-0
@@ -3,16 +3,19 @@ package fr.ebii.card2vcf.ui.sync
| 3 | 3 | import android.content.Context |
| 4 | 4 | import androidx.room.Room |
| 5 | 5 | import androidx.test.core.app.ApplicationProvider |
| 6 | +import fr.ebii.card2vcf.data.CrmContactEntity | |
| 6 | 7 | import fr.ebii.card2vcf.data.CrmDatabase |
| 7 | 8 | import fr.ebii.card2vcf.data.RdvEntity |
| 8 | 9 | import fr.ebii.card2vcf.data.ReservationEntity |
| 9 | 10 | import fr.ebii.card2vcf.sync.AilianceApiClient |
| 10 | 11 | import fr.ebii.card2vcf.sync.CalendarBindingsStore |
| 12 | +import fr.ebii.card2vcf.sync.ContactDto | |
| 11 | 13 | import fr.ebii.card2vcf.sync.FakeAilianceApi |
| 12 | 14 | import fr.ebii.card2vcf.sync.FakeCalendarBridge |
| 13 | 15 | import fr.ebii.card2vcf.sync.SyncCredentialsStore |
| 14 | 16 | import fr.ebii.card2vcf.sync.SyncEngine |
| 15 | 17 | import fr.ebii.card2vcf.sync.SyncOpEntity |
| 18 | +import fr.ebii.card2vcf.sync.SyncPullResponse | |
| 16 | 19 | import kotlinx.coroutines.Dispatchers |
| 17 | 20 | import kotlinx.coroutines.ExperimentalCoroutinesApi |
| 18 | 21 | import kotlinx.coroutines.runBlocking |
@@ -176,4 +179,68 @@ class SyncChromeViewModelTest {
| 176 | 179 | assertNull(vm.conflictPending.value) |
| 177 | 180 | assertTrue(db.reservationDao().getByLocalId(localId)?.conflictPending == true) |
| 178 | 181 | } |
| 182 | + | |
| 183 | + // ---- Résumé de synchro (bandeau) ---- | |
| 184 | + | |
| 185 | + @Test | |
| 186 | + fun syncNow_success_publishesSummaryWithPushedAndReceivedCounts() = runBlocking { | |
| 187 | + val localId = db.crmContactDao().insert(CrmContactEntity(fullName = "Nouveau")) | |
| 188 | + db.syncOpDao().insert( | |
| 189 | + SyncOpEntity(entityType = "contact", op = "create", localId = localId, createdAt = 1L), | |
| 190 | + ) | |
| 191 | + api.pullResult = AilianceApiClient.ApiResult.Ok( | |
| 192 | + SyncPullResponse( | |
| 193 | + serverTime = "2026-07-22T12:05:00Z", | |
| 194 | + contacts = listOf( | |
| 195 | + ContactDto(id = "c1", prenom = "Jean", nom = "Dupont", creeLe = "2026-07-20T10:00:00Z"), | |
| 196 | + ), | |
| 197 | + ), | |
| 198 | + ) | |
| 199 | + val vm = newViewModel() | |
| 200 | + | |
| 201 | + vm.syncNow() | |
| 202 | + | |
| 203 | + awaitUntil { !vm.syncing.value } | |
| 204 | + val summary = vm.syncSummary.value | |
| 205 | + assertEquals(1, summary?.pushed) | |
| 206 | + assertEquals(1, summary?.received) | |
| 207 | + assertEquals(0, summary?.failures) | |
| 208 | + assertNull(summary?.firstFailureMessage) | |
| 209 | + } | |
| 210 | + | |
| 211 | + @Test | |
| 212 | + fun syncNow_pushRefused_publishesSummaryWithVerbatimServerMessage() = runBlocking { | |
| 213 | + val localId = db.rdvDao().upsert(RdvEntity(titre = "Point client", dirtyLocal = true)) | |
| 214 | + db.syncOpDao().insert( | |
| 215 | + SyncOpEntity(entityType = "rdv", op = "create", localId = localId, createdAt = 1L), | |
| 216 | + ) | |
| 217 | + api.createRdvResult = AilianceApiClient.ApiResult.Err( | |
| 218 | + 409, | |
| 219 | + "Réservation refusée : période en conflit avec Entretien", | |
| 220 | + ) | |
| 221 | + val vm = newViewModel() | |
| 222 | + | |
| 223 | + vm.syncNow() | |
| 224 | + | |
| 225 | + awaitUntil { !vm.syncing.value } | |
| 226 | + val summary = vm.syncSummary.value | |
| 227 | + assertEquals(0, summary?.pushed) | |
| 228 | + assertEquals(1, summary?.failures) | |
| 229 | + assertEquals("Réservation refusée : période en conflit avec Entretien", summary?.firstFailureMessage) | |
| 230 | + assertNull(vm.conflictPending.value) | |
| 231 | + } | |
| 232 | + | |
| 233 | + @Test | |
| 234 | + fun syncNow_clearsPreviousSummaryOnGlobalPullFailure() = runBlocking { | |
| 235 | + val vm = newViewModel() | |
| 236 | + vm.syncNow() | |
| 237 | + awaitUntil { vm.syncSummary.value != null } | |
| 238 | + api.pullResult = AilianceApiClient.ApiResult.Err(-1, "Réseau indisponible") | |
| 239 | + | |
| 240 | + vm.syncNow() | |
| 241 | + | |
| 242 | + awaitUntil { !vm.syncing.value } | |
| 243 | + assertNull(vm.syncSummary.value) | |
| 244 | + assertEquals("Réseau indisponible", vm.error.value) | |
| 245 | + } | |
| 179 | 246 | } |
GitRust