serveur sync etapes 2

EBO <eric.bouhana@softalys.com> committé le 2026-07-23 07:01

2f29f664d8c17263be3a25fca3e224d6bcd95b48

1 parent(s)

40 fichiers modifiés +3811 -125
M README.md
+19 -0
@@ -36,6 +36,25 @@ Plan d’implémentation client : [`docs/plans/sync_card2vcf_projectiaon_v1.md`]
36 36
37 37 > **Upgrade schéma v2 :** la migration Room peut réinitialiser les données locales (documenté dans le plan sync).
38 38
39 +## Agenda / calendriers (optionnel, dépend de la sync)
40 +
41 +Card2vcf ne contient **aucun écran agenda** : les rendez-vous et réservations de ressources se consultent et se modifient dans l’**app Agenda système** (calendriers **locaux**, hors-GMS — aucun compte Google requis), via des calendriers dédiés que l’app crée et synchronise avec Projectiaon.
42 +
43 +**Permissions :** `READ_CALENDAR` / `WRITE_CALENDAR`, demandées à l’exécution depuis Paramètres. Sans autorisation, le pont Agenda reste désactivé ; le carnet et la sync CRM (contacts/projets/tâches) continuent de fonctionner normalement.
44 +
45 +**Configuration dans l’app :** Paramètres → section **Calendriers** (visible une fois connecté) :
46 +- Toggle **« Mes RDV »** → crée/lie le calendrier local `Card2vcf — Mes RDV` ; les événements qui y sont créés/modifiés deviennent des RDV Projectiaon à la sync.
47 +- 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 `Card2vcf — {Salle|Matériel|Véhicule} {nom}` ; un événement qui y est créé devient une **réservation Active** poussée au serveur.
48 +- 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.
49 +
50 +**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), Card2vcf conserve l’événement local, retire le blocage serveur pour affichage, puis propose un dialog **« Annuler ma réservation ? »** :
51 +- **Oui** → la réservation locale et son événement Agenda sont supprimés.
52 +- **Non** → rien n’est modifié ; l’arbitrage se fait sur le serveur web (demandes / réclamations restent hors périmètre mobile).
53 +
54 +Spec détaillée : [`docs/superpowers/specs/2026-07-22-sync-agenda-rdv-ressources-v2-design.md`](docs/superpowers/specs/2026-07-22-sync-agenda-rdv-ressources-v2-design.md). Plan d’implémentation : [`docs/plans/sync_agenda_rdv_ressources_v2.md`](docs/plans/sync_agenda_rdv_ressources_v2.md).
55 +
56 +> **Room v3 (agenda) :** l’ajout des tables RDV/réservations/indisponibilités passe la base Room en version 3 avec `fallbackToDestructiveMigration` — comme pour l’upgrade v2 ci-dessus, la mise à jour **réinitialise les données locales** (carnet inclus) ; ré-exportez en VCF ou synchronisez avant de mettre à jour l’app si nécessaire.
57 +
39 58 ## Fonctionnement (scan / OCR)
40 59
41 60 1. Cadrez la carte et capturez (CameraX).
M android/app/src/main/AndroidManifest.xml
+2 -0
@@ -2,6 +2,8 @@
2 2 <manifest xmlns:android="http://schemas.android.com/apk/res/android">
3 3 <uses-permission android:name="android.permission.CAMERA" />
4 4 <uses-permission android:name="android.permission.INTERNET" />
5 + <uses-permission android:name="android.permission.READ_CALENDAR" />
6 + <uses-permission android:name="android.permission.WRITE_CALENDAR" />
5 7 <uses-feature android:name="android.hardware.camera" android:required="true" />
6 8
7 9 <application
M android/app/src/main/java/fr/ebii/card2vcf/data/CrmDatabase.kt
+8 -2
@@ -22,8 +22,11 @@ import fr.ebii.card2vcf.sync.SyncOpEntity
22 22 WorkflowEntity::class,
23 23 SyncOpEntity::class,
24 24 SyncMetaEntity::class,
25 + RdvEntity::class,
26 + ReservationEntity::class,
27 + IndisponibiliteEntity::class,
25 28 ],
26 - version = 2,
29 + version = 3,
27 30 exportSchema = false,
28 31 )
29 32 @TypeConverters(Converters::class)
@@ -36,12 +39,15 @@ abstract class CrmDatabase : RoomDatabase() {
36 39 abstract fun workflowDao(): WorkflowDao
37 40 abstract fun syncOpDao(): SyncOpDao
38 41 abstract fun syncMetaDao(): SyncMetaDao
42 + abstract fun rdvDao(): RdvDao
43 + abstract fun reservationDao(): ReservationDao
44 + abstract fun indisponibiliteDao(): IndisponibiliteDao
39 45
40 46 companion object {
41 47 @Volatile private var instance: CrmDatabase? = null
42 48
43 49 /**
44 - * v1 sync schema : pas de migration incrémentale — reset local à l'upgrade (acceptable v1).
50 + * v1/v2/v3 sync schema : pas de migration incrémentale — reset local à l'upgrade (acceptable v1-v3).
45 51 * Les tests utilisent Room.inMemoryDatabaseBuilder sans fallback destructif.
46 52 */
47 53 fun get(context: Context): CrmDatabase =
A android/app/src/main/java/fr/ebii/card2vcf/data/IndisponibiliteDao.kt
+24 -0
@@ -0,0 +1,24 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Dao
4 +import androidx.room.Insert
5 +import androidx.room.OnConflictStrategy
6 +import androidx.room.Query
7 +
8 +@Dao
9 +interface IndisponibiliteDao {
10 + @Insert(onConflict = OnConflictStrategy.REPLACE)
11 + suspend fun upsert(entity: IndisponibiliteEntity): Long
12 +
13 + @Query("SELECT * FROM indisponibilites WHERE serverId = :serverId")
14 + suspend fun getByServerId(serverId: String): IndisponibiliteEntity?
15 +
16 + @Query("DELETE FROM indisponibilites WHERE serverId = :serverId")
17 + suspend fun deleteByServerId(serverId: String)
18 +
19 + @Query("SELECT * FROM indisponibilites WHERE cibleType = :cibleType AND cibleId = :cibleId")
20 + suspend fun listByCible(cibleType: String, cibleId: String): List<IndisponibiliteEntity>
21 +
22 + @Query("SELECT * FROM indisponibilites")
23 + suspend fun listAll(): List<IndisponibiliteEntity>
24 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/IndisponibiliteEntity.kt
+19 -0
@@ -0,0 +1,19 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Entity
4 +import androidx.room.PrimaryKey
5 +
6 +/** Miroir lecture seule ; pas de push local (indispos gérées côté serveur). */
7 +@Entity(tableName = "indisponibilites")
8 +data class IndisponibiliteEntity(
9 + @PrimaryKey(autoGenerate = true) val localId: Long = 0,
10 + val serverId: String? = null,
11 + val cibleType: String = "",
12 + val cibleId: String = "",
13 + val debutMs: Long = 0L,
14 + val finMs: Long = 0L,
15 + val nature: String? = null,
16 + val commentaire: String? = null,
17 + val updatedAt: Long = 0L,
18 + val calendarEventId: Long? = null,
19 +)
A android/app/src/main/java/fr/ebii/card2vcf/data/RdvDao.kt
+33 -0
@@ -0,0 +1,33 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Dao
4 +import androidx.room.Insert
5 +import androidx.room.OnConflictStrategy
6 +import androidx.room.Query
7 +
8 +@Dao
9 +interface RdvDao {
10 + @Insert(onConflict = OnConflictStrategy.REPLACE)
11 + suspend fun upsert(entity: RdvEntity): Long
12 +
13 + @Query("SELECT * FROM rdv WHERE serverId = :serverId")
14 + suspend fun getByServerId(serverId: String): RdvEntity?
15 +
16 + @Query("SELECT * FROM rdv WHERE localId = :localId")
17 + suspend fun getByLocalId(localId: Long): RdvEntity?
18 +
19 + @Query("SELECT * FROM rdv WHERE calendarEventId = :calendarEventId")
20 + suspend fun getByCalendarEventId(calendarEventId: Long): RdvEntity?
21 +
22 + @Query("SELECT * FROM rdv WHERE dirtyLocal = 1")
23 + suspend fun listDirty(): List<RdvEntity>
24 +
25 + @Query("DELETE FROM rdv WHERE serverId = :serverId")
26 + suspend fun deleteByServerId(serverId: String)
27 +
28 + @Query("DELETE FROM rdv WHERE localId = :localId")
29 + suspend fun deleteByLocalId(localId: Long)
30 +
31 + @Query("SELECT * FROM rdv")
32 + suspend fun listAll(): List<RdvEntity>
33 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/RdvEntity.kt
+20 -0
@@ -0,0 +1,20 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Entity
4 +import androidx.room.PrimaryKey
5 +
6 +@Entity(tableName = "rdv")
7 +data class RdvEntity(
8 + @PrimaryKey(autoGenerate = true) val localId: Long = 0,
9 + val serverId: String? = null,
10 + val titre: String = "",
11 + val description: String = "",
12 + val lieu: String = "",
13 + val debutMs: Long = 0L,
14 + val finMs: Long = 0L,
15 + val contactIdsJson: String = "[]",
16 + val projetId: String? = null,
17 + val updatedAt: Long = 0L,
18 + val calendarEventId: Long? = null,
19 + val dirtyLocal: Boolean = false,
20 +)
A android/app/src/main/java/fr/ebii/card2vcf/data/ReservationDao.kt
+36 -0
@@ -0,0 +1,36 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Dao
4 +import androidx.room.Insert
5 +import androidx.room.OnConflictStrategy
6 +import androidx.room.Query
7 +
8 +@Dao
9 +interface ReservationDao {
10 + @Insert(onConflict = OnConflictStrategy.REPLACE)
11 + suspend fun upsert(entity: ReservationEntity): Long
12 +
13 + @Query("SELECT * FROM reservations WHERE serverId = :serverId")
14 + suspend fun getByServerId(serverId: String): ReservationEntity?
15 +
16 + @Query("SELECT * FROM reservations WHERE localId = :localId")
17 + suspend fun getByLocalId(localId: Long): ReservationEntity?
18 +
19 + @Query("SELECT * FROM reservations WHERE calendarEventId = :calendarEventId")
20 + suspend fun getByCalendarEventId(calendarEventId: Long): ReservationEntity?
21 +
22 + @Query("SELECT * FROM reservations WHERE dirtyLocal = 1")
23 + suspend fun listDirty(): List<ReservationEntity>
24 +
25 + @Query("DELETE FROM reservations WHERE serverId = :serverId")
26 + suspend fun deleteByServerId(serverId: String)
27 +
28 + @Query("DELETE FROM reservations WHERE localId = :localId")
29 + suspend fun deleteByLocalId(localId: Long)
30 +
31 + @Query("SELECT * FROM reservations WHERE cibleType = :cibleType AND cibleId = :cibleId")
32 + suspend fun listByCible(cibleType: String, cibleId: String): List<ReservationEntity>
33 +
34 + @Query("SELECT * FROM reservations")
35 + suspend fun listAll(): List<ReservationEntity>
36 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/ReservationEntity.kt
+20 -0
@@ -0,0 +1,20 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Entity
4 +import androidx.room.PrimaryKey
5 +
6 +@Entity(tableName = "reservations")
7 +data class ReservationEntity(
8 + @PrimaryKey(autoGenerate = true) val localId: Long = 0,
9 + val serverId: String? = null,
10 + val cibleType: String = "",
11 + val cibleId: String = "",
12 + val debutMs: Long = 0L,
13 + val finMs: Long = 0L,
14 + val motif: String = "",
15 + val statut: String = "active",
16 + val updatedAt: Long = 0L,
17 + val calendarEventId: Long? = null,
18 + val dirtyLocal: Boolean = false,
19 + val conflictPending: Boolean = false,
20 +)
A android/app/src/main/java/fr/ebii/card2vcf/sync/AgendaSyncCoordinator.kt
+431 -0
@@ -0,0 +1,431 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import fr.ebii.card2vcf.data.CrmDatabase
4 +import fr.ebii.card2vcf.data.IndisponibiliteEntity
5 +import fr.ebii.card2vcf.data.RdvEntity
6 +import fr.ebii.card2vcf.data.ReservationEntity
7 +import kotlinx.serialization.decodeFromString
8 +import kotlinx.serialization.encodeToString
9 +
10 +/** Conflit de réservation (409) à faire trancher à l'utilisateur : garder ou `abandonLocalReservation`. */
11 +data class AgendaConflict(
12 + val localReservationId: Long,
13 + val message: String? = null,
14 +)
15 +
16 +/**
17 + * Pont Agenda↔Room et mapping DTO↔entité pour RDV / réservations / indisponibilités.
18 + * Extrait de [SyncEngine] pour garder l'orchestration CRM+agenda lisible ; [SyncEngine] reste
19 + * seul responsable de la file [SyncOpEntity] (push) et du watermark.
20 + */
21 +class AgendaSyncCoordinator(
22 + private val db: CrmDatabase,
23 +) {
24 + /** `salle:id,materiel:id,...` à partir des liaisons non-`rdv` qui portent un `serverResourceId`. */
25 + fun ressourcesQuery(bindings: List<CalendarBinding>): String? =
26 + Companion.ressourcesQuery(bindings)
27 +
28 +
29 + // ---- Agenda -> Room (événements créés/modifiés dans l'app Agenda système) ----
30 +
31 + suspend fun agendaToRoom(bindings: List<CalendarBinding>, bridge: CalendarBridgeApi) {
32 + for (binding in bindings) {
33 + val calendarId = binding.androidCalendarId ?: continue
34 + if (binding.kind == KIND_RDV) {
35 + agendaEventsToRdv(calendarId, bridge)
36 + } else {
37 + agendaEventsToReservation(binding, calendarId, bridge)
38 + }
39 + }
40 + }
41 +
42 + private suspend fun agendaEventsToRdv(calendarId: Long, bridge: CalendarBridgeApi) {
43 + val events = bridge.listEvents(calendarId)
44 + val presentEventIds = events.mapNotNull { it.eventId }.toSet()
45 +
46 + for (snap in events) {
47 + val eventId = snap.eventId ?: continue
48 + val local = db.rdvDao().getByCalendarEventId(eventId)
49 + if (local == null) {
50 + val entity = RdvEntity(
51 + titre = snap.title,
52 + description = snap.descriptionBody,
53 + debutMs = snap.debutMs,
54 + finMs = snap.finMs,
55 + calendarEventId = eventId,
56 + updatedAt = System.currentTimeMillis(),
57 + dirtyLocal = true,
58 + )
59 + val localId = db.rdvDao().upsert(entity)
60 + enqueueOp(KIND_RDV, "create", localId, null, rdvPayload(entity))
61 + continue
62 + }
63 + if (!local.differsFrom(snap)) continue
64 + val updated = local.copy(
65 + titre = snap.title,
66 + description = snap.descriptionBody,
67 + debutMs = snap.debutMs,
68 + finMs = snap.finMs,
69 + updatedAt = System.currentTimeMillis(),
70 + dirtyLocal = true,
71 + )
72 + db.rdvDao().upsert(updated)
73 + enqueueOp(KIND_RDV, if (local.serverId == null) "create" else "update", local.localId, local.serverId, rdvPayload(updated))
74 + }
75 +
76 + // Event supprimé côté app Agenda système : répercuter la suppression (SyncOp si déjà connu du serveur).
77 + for (rdv in db.rdvDao().listAll()) {
78 + val eventId = rdv.calendarEventId ?: continue
79 + if (eventId in presentEventIds) continue
80 + if (rdv.serverId != null) {
81 + enqueueOp(KIND_RDV, "delete", rdv.localId, rdv.serverId, "{}")
82 + }
83 + db.rdvDao().deleteByLocalId(rdv.localId)
84 + }
85 + }
86 +
87 + private fun RdvEntity.differsFrom(snap: CalendarEventSnapshot): Boolean =
88 + titre != snap.title || description != snap.descriptionBody || debutMs != snap.debutMs || finMs != snap.finMs
89 +
90 + private suspend fun agendaEventsToReservation(binding: CalendarBinding, calendarId: Long, bridge: CalendarBridgeApi) {
91 + val cibleId = binding.serverResourceId ?: return
92 + val cibleType = binding.kind
93 + val events = bridge.listEvents(calendarId)
94 + val presentEventIds = events.mapNotNull { it.eventId }.toSet()
95 +
96 + for (snap in events) {
97 + val eventId = snap.eventId ?: continue
98 + if (CalendarEventTitles.isIndispoTitle(snap.title)) continue // miroir lecture, pas de push
99 + val local = db.reservationDao().getByCalendarEventId(eventId)
100 + if (local == null) {
101 + val entity = ReservationEntity(
102 + cibleType = cibleType,
103 + cibleId = cibleId,
104 + debutMs = snap.debutMs,
105 + finMs = snap.finMs,
106 + motif = snap.descriptionBody,
107 + calendarEventId = eventId,
108 + updatedAt = System.currentTimeMillis(),
109 + dirtyLocal = true,
110 + )
111 + val localId = db.reservationDao().upsert(entity)
112 + enqueueOp(ENTITY_RESERVATION, "create", localId, null, reservationPayload(entity))
113 + continue
114 + }
115 + if (local.conflictPending) continue // en attente de décision utilisateur, ne pas re-pousser
116 + if (!local.differsFrom(snap)) continue
117 + val updated = local.copy(
118 + debutMs = snap.debutMs,
119 + finMs = snap.finMs,
120 + motif = snap.descriptionBody,
121 + updatedAt = System.currentTimeMillis(),
122 + dirtyLocal = true,
123 + )
124 + db.reservationDao().upsert(updated)
125 + enqueueOp(
126 + ENTITY_RESERVATION,
127 + if (local.serverId == null) "create" else "update",
128 + local.localId,
129 + local.serverId,
130 + reservationPayload(updated),
131 + )
132 + }
133 +
134 + // Event supprimé côté app Agenda système : répercuter la suppression (SyncOp si déjà connu du serveur).
135 + for (reservation in db.reservationDao().listByCible(cibleType, cibleId)) {
136 + val eventId = reservation.calendarEventId ?: continue
137 + if (eventId in presentEventIds) continue
138 + if (reservation.conflictPending) continue // décision utilisateur en attente (voir abandonLocalReservation)
139 + if (reservation.serverId != null) {
140 + enqueueOp(ENTITY_RESERVATION, "delete", reservation.localId, reservation.serverId, cibleRefPayload(cibleType, cibleId))
141 + }
142 + db.reservationDao().deleteByLocalId(reservation.localId)
143 + }
144 + }
145 +
146 + private fun ReservationEntity.differsFrom(snap: CalendarEventSnapshot): Boolean =
147 + motif != snap.descriptionBody || debutMs != snap.debutMs || finMs != snap.finMs
148 +
149 + /** Remplace toute op en attente pour cette entité locale avant d'insérer la nouvelle (évite l'accumulation). */
150 + private suspend fun enqueueOp(entityType: String, op: String, localId: Long, serverId: String?, payloadJson: String) {
151 + db.syncOpDao().listAll()
152 + .filter { it.entityType == entityType && it.localId == localId }
153 + .forEach { db.syncOpDao().deleteById(it.id) }
154 + db.syncOpDao().insert(
155 + SyncOpEntity(
156 + entityType = entityType,
157 + op = op,
158 + payloadJson = payloadJson,
159 + localId = localId,
160 + serverId = serverId,
161 + createdAt = System.currentTimeMillis(),
162 + ),
163 + )
164 + }
165 +
166 + private fun rdvPayload(entity: RdvEntity): String {
167 + val contactIds = try {
168 + syncJson.decodeFromString<List<String>>(entity.contactIdsJson)
169 + } catch (_: Exception) {
170 + emptyList()
171 + }
172 + return syncJson.encodeToString(
173 + RdvUpsertRequest(
174 + titre = entity.titre,
175 + description = entity.description,
176 + lieu = entity.lieu,
177 + debut = epochMsToIso(entity.debutMs),
178 + fin = epochMsToIso(entity.finMs),
179 + contactIds = contactIds,
180 + projetId = entity.projetId,
181 + ),
182 + )
183 + }
184 +
185 + private fun reservationPayload(entity: ReservationEntity): String =
186 + syncJson.encodeToString(
187 + ReservationUpsertRequest(
188 + debut = epochMsToIso(entity.debutMs),
189 + fin = epochMsToIso(entity.finMs),
190 + motif = entity.motif,
191 + ),
192 + )
193 +
194 + /**
195 + * Payload d'un SyncOp `delete` réservation : porte `(cibleType, cibleId)` pour que
196 + * [SyncEngine] puisse résoudre le genre/id serveur même après suppression de l'entité locale
197 + * (voir `resolveReservationCible`). Réutilise [CibleRessourceDto], déjà utilisé pour `cible`.
198 + */
199 + private fun cibleRefPayload(cibleType: String, cibleId: String): String =
200 + syncJson.encodeToString(CibleRessourceDto(type = cibleType, id = cibleId))
201 +
202 + // ---- Room -> Agenda (après application du pull) ----
203 +
204 + suspend fun roomToAgenda(bindings: List<CalendarBinding>, bridge: CalendarBridgeApi) {
205 + for (binding in bindings) {
206 + val calendarId = binding.androidCalendarId ?: continue
207 + if (binding.kind == KIND_RDV) {
208 + rdvToAgenda(calendarId, bridge)
209 + } else {
210 + resourceEntitiesToAgenda(binding, calendarId, bridge)
211 + }
212 + }
213 + }
214 +
215 + private suspend fun rdvToAgenda(calendarId: Long, bridge: CalendarBridgeApi) {
216 + for (rdv in db.rdvDao().listAll()) {
217 + val eventId = bridge.upsertEvent(
218 + calendarId,
219 + CalendarEventSnapshot(
220 + title = rdv.titre,
221 + debutMs = rdv.debutMs,
222 + finMs = rdv.finMs,
223 + eventId = rdv.calendarEventId,
224 + serverId = rdv.serverId,
225 + descriptionBody = rdv.description,
226 + ),
227 + )
228 + if (eventId != rdv.calendarEventId) db.rdvDao().upsert(rdv.copy(calendarEventId = eventId))
229 + }
230 + deleteOrphanEvents(calendarId, bridge, rdvKeptIds())
231 + }
232 +
233 + private suspend fun resourceEntitiesToAgenda(binding: CalendarBinding, calendarId: Long, bridge: CalendarBridgeApi) {
234 + val cibleId = binding.serverResourceId ?: return
235 + val cibleType = binding.kind
236 + for (reservation in db.reservationDao().listByCible(cibleType, cibleId)) {
237 + if (reservation.statut != STATUT_ACTIVE) continue
238 + val eventId = bridge.upsertEvent(
239 + calendarId,
240 + CalendarEventSnapshot(
241 + title = reservation.motif.ifBlank { "Réservation" },
242 + debutMs = reservation.debutMs,
243 + finMs = reservation.finMs,
244 + eventId = reservation.calendarEventId,
245 + serverId = reservation.serverId,
246 + ),
247 + )
248 + if (eventId != reservation.calendarEventId) {
249 + db.reservationDao().upsert(reservation.copy(calendarEventId = eventId))
250 + }
251 + }
252 + for (indispo in db.indisponibiliteDao().listByCible(cibleType, cibleId)) {
253 + val eventId = bridge.upsertEvent(
254 + calendarId,
255 + CalendarEventSnapshot(
256 + title = CalendarEventTitles.indispoTitle(indispo.commentaire?.ifBlank { null } ?: indispo.nature ?: "Indisponible"),
257 + debutMs = indispo.debutMs,
258 + finMs = indispo.finMs,
259 + eventId = indispo.calendarEventId,
260 + serverId = indispo.serverId,
261 + ),
262 + )
263 + if (eventId != indispo.calendarEventId) {
264 + db.indisponibiliteDao().upsert(indispo.copy(calendarEventId = eventId))
265 + }
266 + }
267 + deleteOrphanEvents(calendarId, bridge, resourceKeptIds(cibleType, cibleId))
268 + }
269 +
270 + // ---- Events orphelins (Room a supprimé l'entité, l'event Agenda doit suivre) ----
271 +
272 + private suspend fun rdvKeptIds(): Pair<Set<String>, Set<Long>> {
273 + val entities = db.rdvDao().listAll()
274 + return entities.mapNotNull { it.serverId }.toSet() to entities.mapNotNull { it.calendarEventId }.toSet()
275 + }
276 +
277 + private suspend fun resourceKeptIds(cibleType: String, cibleId: String): Pair<Set<String>, Set<Long>> {
278 + val reservations = db.reservationDao().listByCible(cibleType, cibleId).filter { it.statut == STATUT_ACTIVE }
279 + val indispos = db.indisponibiliteDao().listByCible(cibleType, cibleId)
280 + val serverIds = reservations.mapNotNull { it.serverId }.toSet() + indispos.mapNotNull { it.serverId }
281 + val eventIds = reservations.mapNotNull { it.calendarEventId }.toSet() + indispos.mapNotNull { it.calendarEventId }
282 + return serverIds to eventIds
283 + }
284 +
285 + /**
286 + * `deleteEvent` pour tout event `card2vcf:serverId=` (voir [CalendarServerIdCodec]) de [calendarId]
287 + * dont ni le `serverId` ni le `eventId` ne correspondent plus à une entité Room ([kept]).
288 + * Les events non tagués (pas encore poussés, ou étrangers à card2vcf) ne sont jamais supprimés.
289 + */
290 + private fun deleteOrphanEvents(calendarId: Long, bridge: CalendarBridgeApi, kept: Pair<Set<String>, Set<Long>>) {
291 + orphanEvents(calendarId, bridge, kept).forEach { bridge.deleteEvent(it) }
292 + }
293 +
294 + private fun orphanEvents(calendarId: Long, bridge: CalendarBridgeApi, kept: Pair<Set<String>, Set<Long>>): List<Long> {
295 + val (keptServerIds, keptEventIds) = kept
296 + return bridge.listEvents(calendarId).mapNotNull { snap ->
297 + val eventId = snap.eventId ?: return@mapNotNull null
298 + val serverId = snap.serverId ?: return@mapNotNull null // non tagué card2vcf : jamais géré ici
299 + if (serverId in keptServerIds || eventId in keptEventIds) null else eventId
300 + }
301 + }
302 +
303 + /** Nombre d'events card2vcf-tagués dans les calendriers liés qui n'ont plus d'entité Room associée. */
304 + suspend fun orphanEventCount(bindings: List<CalendarBinding>, bridge: CalendarBridgeApi): Int {
305 + var count = 0
306 + for (binding in bindings) {
307 + val calendarId = binding.androidCalendarId ?: continue
308 + val kept = if (binding.kind == KIND_RDV) {
309 + rdvKeptIds()
310 + } else {
311 + val cibleId = binding.serverResourceId ?: continue
312 + resourceKeptIds(binding.kind, cibleId)
313 + }
314 + count += orphanEvents(calendarId, bridge, kept).size
315 + }
316 + return count
317 + }
318 +
319 + // ---- Pull : DTO -> entités (LWW RDV, résas actives, indispos lecture) ----
320 +
321 + suspend fun applyRdvPull(dto: RendezVousDto) {
322 + val remoteTs = parseIsoToEpochMs(dto.misAJourLe ?: dto.creeLe)
323 + val local = db.rdvDao().getByServerId(dto.id)
324 + if (local == null) {
325 + db.rdvDao().upsert(
326 + RdvEntity(
327 + serverId = dto.id,
328 + titre = dto.titre,
329 + description = dto.description,
330 + lieu = dto.lieu,
331 + debutMs = parseIsoToEpochMs(dto.debut),
332 + finMs = parseIsoToEpochMs(dto.fin),
333 + contactIdsJson = syncJson.encodeToString(dto.contactIds),
334 + projetId = dto.projetId,
335 + updatedAt = remoteTs,
336 + ),
337 + )
338 + return
339 + }
340 + val remoteEntity = local.copy(
341 + titre = dto.titre,
342 + description = dto.description,
343 + lieu = dto.lieu,
344 + debutMs = parseIsoToEpochMs(dto.debut),
345 + finMs = parseIsoToEpochMs(dto.fin),
346 + contactIdsJson = syncJson.encodeToString(dto.contactIds),
347 + projetId = dto.projetId,
348 + updatedAt = remoteTs,
349 + dirtyLocal = false,
350 + )
351 + val merged = LwwMerger.pickLww(local, local.updatedAt, remoteEntity, remoteTs)
352 + if (merged !== local) db.rdvDao().upsert(merged)
353 + }
354 +
355 + suspend fun applyReservationPull(dto: ReservationDto) {
356 + val local = db.reservationDao().getByServerId(dto.id)
357 + if (dto.statut != STATUT_ACTIVE) {
358 + if (local != null) db.reservationDao().deleteByServerId(dto.id)
359 + return
360 + }
361 + val remoteTs = parseIsoToEpochMs(dto.misAJourLe ?: dto.creeLe)
362 + if (local == null) {
363 + db.reservationDao().upsert(
364 + ReservationEntity(
365 + serverId = dto.id,
366 + cibleType = dto.cible.type,
367 + cibleId = dto.cible.id,
368 + debutMs = parseIsoToEpochMs(dto.debut),
369 + finMs = parseIsoToEpochMs(dto.fin),
370 + motif = dto.motif,
371 + statut = dto.statut,
372 + updatedAt = remoteTs,
373 + ),
374 + )
375 + return
376 + }
377 + val remoteEntity = local.copy(
378 + cibleType = dto.cible.type,
379 + cibleId = dto.cible.id,
380 + debutMs = parseIsoToEpochMs(dto.debut),
381 + finMs = parseIsoToEpochMs(dto.fin),
382 + motif = dto.motif,
383 + statut = dto.statut,
384 + updatedAt = remoteTs,
385 + dirtyLocal = false,
386 + conflictPending = false,
387 + )
388 + val merged = LwwMerger.pickLww(local, local.updatedAt, remoteEntity, remoteTs)
389 + if (merged !== local) db.reservationDao().upsert(merged)
390 + }
391 +
392 + suspend fun applyIndisponibilitePull(dto: IndisponibiliteDto) {
393 + val remoteTs = parseIsoToEpochMs(dto.misAJourLe ?: dto.creeLe)
394 + val local = db.indisponibiliteDao().getByServerId(dto.id)
395 + val entity = (local ?: IndisponibiliteEntity(serverId = dto.id)).copy(
396 + cibleType = dto.cible.type,
397 + cibleId = dto.cible.id,
398 + debutMs = parseIsoToEpochMs(dto.debut),
399 + finMs = parseIsoToEpochMs(dto.fin),
400 + nature = dto.nature,
401 + commentaire = dto.commentaire,
402 + updatedAt = remoteTs,
403 + )
404 + db.indisponibiliteDao().upsert(entity)
405 + }
406 +
407 + suspend fun applyAgendaTombstone(dto: TombstoneDto): Boolean {
408 + when (dto.entityType) {
409 + "rdv" -> db.rdvDao().deleteByServerId(dto.id)
410 + "reservation" -> db.reservationDao().deleteByServerId(dto.id)
411 + "indisponibilite" -> db.indisponibiliteDao().deleteByServerId(dto.id)
412 + else -> return false
413 + }
414 + return true
415 + }
416 +
417 + companion object {
418 + /** [CalendarBinding.kind] pour la liaison « Mes RDV » ; sert aussi d'`entityType` [SyncOpEntity]. */
419 + const val KIND_RDV = "rdv"
420 + const val ENTITY_RESERVATION = "reservation"
421 + private const val STATUT_ACTIVE = "active"
422 +
423 + /** `salle:id,materiel:id,...` — utilisable sans instance (bandeau status). */
424 + fun ressourcesQuery(bindings: List<CalendarBinding>): String? {
425 + val parts = bindings
426 + .filter { it.kind != KIND_RDV && it.serverResourceId != null }
427 + .map { "${it.kind}:${it.serverResourceId}" }
428 + return parts.takeIf { it.isNotEmpty() }?.joinToString(",")
429 + }
430 + }
431 +}
M android/app/src/main/java/fr/ebii/card2vcf/sync/AilianceApi.kt
+29 -2
@@ -4,9 +4,9 @@ package fr.ebii.card2vcf.sync
4 4 interface AilianceApi {
5 5 fun authCle(nom: String, motDePasse: String): AilianceApiClient.ApiResult<AuthCleResponse>
6 6
7 - fun syncStatus(sinceIso: String): AilianceApiClient.ApiResult<SyncStatusResponse>
7 + fun syncStatus(sinceIso: String, ressourcesQuery: String? = null): AilianceApiClient.ApiResult<SyncStatusResponse>
8 8
9 - fun syncPull(sinceIso: String): AilianceApiClient.ApiResult<SyncPullResponse>
9 + fun syncPull(sinceIso: String, ressourcesQuery: String? = null): AilianceApiClient.ApiResult<SyncPullResponse>
10 10
11 11 fun createContact(jsonBody: String): AilianceApiClient.ApiResult<String>
12 12
@@ -35,4 +35,31 @@ interface AilianceApi {
35 35 fun moveTache(projetId: String, tacheId: String, jsonBody: String): AilianceApiClient.ApiResult<String>
36 36
37 37 fun createInteraction(contactId: String, jsonBody: String): AilianceApiClient.ApiResult<String>
38 +
39 + fun listRdv(): AilianceApiClient.ApiResult<List<RendezVousDto>>
40 +
41 + fun createRdv(jsonBody: String): AilianceApiClient.ApiResult<String>
42 +
43 + fun getRdv(id: String): AilianceApiClient.ApiResult<RendezVousDto>
44 +
45 + fun updateRdv(id: String, jsonBody: String): AilianceApiClient.ApiResult<String>
46 +
47 + fun deleteRdv(id: String): AilianceApiClient.ApiResult<Unit>
48 +
49 + fun getRessourcesCatalogue(): AilianceApiClient.ApiResult<RessourcesCatalogueDto>
50 +
51 + fun listReservations(genre: String, resourceId: String): AilianceApiClient.ApiResult<List<ReservationDto>>
52 +
53 + fun createReservation(genre: String, resourceId: String, jsonBody: String): AilianceApiClient.ApiResult<String>
54 +
55 + fun updateReservation(
56 + genre: String,
57 + resourceId: String,
58 + reservationId: String,
59 + jsonBody: String,
60 + ): AilianceApiClient.ApiResult<String>
61 +
62 + fun deleteReservation(genre: String, resourceId: String, reservationId: String): AilianceApiClient.ApiResult<Unit>
63 +
64 + fun listIndisponibilites(genre: String, resourceId: String): AilianceApiClient.ApiResult<List<IndisponibiliteDto>>
38 65 }
M android/app/src/main/java/fr/ebii/card2vcf/sync/AilianceApiClient.kt
+53 -4
@@ -1,5 +1,6 @@
1 1 package fr.ebii.card2vcf.sync
2 2
3 +import kotlinx.serialization.builtins.ListSerializer
3 4 import okhttp3.MediaType.Companion.toMediaType
4 5 import okhttp3.OkHttpClient
5 6 import okhttp3.Request
@@ -26,13 +27,13 @@ class AilianceApiClient(
26 27 }
27 28 }
28 29
29 - override fun syncStatus(sinceIso: String): ApiResult<SyncStatusResponse> =
30 - get("/api/sync/status?since=${encodeQuery(sinceIso)}") { body ->
30 + override fun syncStatus(sinceIso: String, ressourcesQuery: String?): ApiResult<SyncStatusResponse> =
31 + get("/api/sync/status?since=${encodeQuery(sinceIso)}${ressourcesQueryParam(ressourcesQuery)}") { body ->
31 32 syncJson.decodeFromString(SyncStatusResponse.serializer(), body)
32 33 }
33 34
34 - override fun syncPull(sinceIso: String): ApiResult<SyncPullResponse> =
35 - get("/api/sync/pull?since=${encodeQuery(sinceIso)}") { body ->
35 + override fun syncPull(sinceIso: String, ressourcesQuery: String?): ApiResult<SyncPullResponse> =
36 + get("/api/sync/pull?since=${encodeQuery(sinceIso)}${ressourcesQueryParam(ressourcesQuery)}") { body ->
36 37 syncJson.decodeFromString(SyncPullResponse.serializer(), body)
37 38 }
38 39
@@ -78,6 +79,51 @@ class AilianceApiClient(
78 79 override fun createInteraction(contactId: String, jsonBody: String): ApiResult<String> =
79 80 post("/api/contacts/${encodePath(contactId)}/interactions", jsonBody) { it }
80 81
82 + override fun listRdv(): ApiResult<List<RendezVousDto>> =
83 + get("/api/rdv") { body -> syncJson.decodeFromString(ListSerializer(RendezVousDto.serializer()), body) }
84 +
85 + override fun createRdv(jsonBody: String): ApiResult<String> =
86 + post("/api/rdv", jsonBody) { it }
87 +
88 + override fun getRdv(id: String): ApiResult<RendezVousDto> =
89 + get("/api/rdv/${encodePath(id)}") { body -> syncJson.decodeFromString(RendezVousDto.serializer(), body) }
90 +
91 + override fun updateRdv(id: String, jsonBody: String): ApiResult<String> =
92 + put("/api/rdv/${encodePath(id)}", jsonBody) { it }
93 +
94 + override fun deleteRdv(id: String): ApiResult<Unit> =
95 + delete("/api/rdv/${encodePath(id)}")
96 +
97 + override fun getRessourcesCatalogue(): ApiResult<RessourcesCatalogueDto> =
98 + get("/api/ressources") { body -> syncJson.decodeFromString(RessourcesCatalogueDto.serializer(), body) }
99 +
100 + override fun listReservations(genre: String, resourceId: String): ApiResult<List<ReservationDto>> =
101 + get("/api/ressources/${encodePath(genre)}/${encodePath(resourceId)}/reservations") { body ->
102 + syncJson.decodeFromString(ListSerializer(ReservationDto.serializer()), body)
103 + }
104 +
105 + override fun createReservation(genre: String, resourceId: String, jsonBody: String): ApiResult<String> =
106 + post("/api/ressources/${encodePath(genre)}/${encodePath(resourceId)}/reservations", jsonBody) { it }
107 +
108 + override fun updateReservation(
109 + genre: String,
110 + resourceId: String,
111 + reservationId: String,
112 + jsonBody: String,
113 + ): ApiResult<String> =
114 + put(
115 + "/api/ressources/${encodePath(genre)}/${encodePath(resourceId)}/reservations/${encodePath(reservationId)}",
116 + jsonBody,
117 + ) { it }
118 +
119 + override fun deleteReservation(genre: String, resourceId: String, reservationId: String): ApiResult<Unit> =
120 + delete("/api/ressources/${encodePath(genre)}/${encodePath(resourceId)}/reservations/${encodePath(reservationId)}")
121 +
122 + override fun listIndisponibilites(genre: String, resourceId: String): ApiResult<List<IndisponibiliteDto>> =
123 + get("/api/ressources/${encodePath(genre)}/${encodePath(resourceId)}/indisponibilites") { body ->
124 + syncJson.decodeFromString(ListSerializer(IndisponibiliteDto.serializer()), body)
125 + }
126 +
81 127 private inline fun <T> get(path: String, crossinline parse: (String) -> T): ApiResult<T> =
82 128 execute(buildRequest("GET", path, null, useBearer = true), parse)
83 129
@@ -158,6 +204,9 @@ class AilianceApiClient(
158 204 val error: String? = null,
159 205 )
160 206
207 + private fun ressourcesQueryParam(ressourcesQuery: String?): String =
208 + if (ressourcesQuery.isNullOrBlank()) "" else "&ressources=${encodeQuery(ressourcesQuery)}"
209 +
161 210 private companion object {
162 211 fun encodeQuery(value: String): String =
163 212 java.net.URLEncoder.encode(value, Charsets.UTF_8)
A android/app/src/main/java/fr/ebii/card2vcf/sync/CalendarBindingsStore.kt
+62 -0
@@ -0,0 +1,62 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import android.content.Context
4 +import android.content.SharedPreferences
5 +import kotlinx.serialization.Serializable
6 +import kotlinx.serialization.decodeFromString
7 +import kotlinx.serialization.encodeToString
8 +import kotlinx.serialization.json.Json
9 +
10 +/** Liaison entre un calendrier Android local et une entité Projectiaon (RDV ou ressource réservable). */
11 +@Serializable
12 +data class CalendarBinding(
13 + val kind: String, // "rdv" | "salle" | "materiel" | "vehicule"
14 + val serverResourceId: String? = null,
15 + val displayName: String,
16 + val androidCalendarId: Long? = null,
17 +)
18 +
19 +/** Persiste les [CalendarBinding] liés par l'utilisateur en Paramètres, en JSON dans SharedPreferences. */
20 +class CalendarBindingsStore internal constructor(
21 + private val prefs: SharedPreferences,
22 +) {
23 + constructor(context: Context) : this(
24 + context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE),
25 + )
26 +
27 + fun list(): List<CalendarBinding> {
28 + val raw = prefs.getString(KEY_BINDINGS, null) ?: return emptyList()
29 + return try {
30 + json.decodeFromString(raw)
31 + } catch (e: Exception) {
32 + emptyList()
33 + }
34 + }
35 +
36 + /** Remplace la liaison existante de même [CalendarBinding.kind]/[CalendarBinding.serverResourceId], sinon l'ajoute. */
37 + fun upsert(binding: CalendarBinding) {
38 + val updated = list().filterNot { it.matches(binding.kind, binding.serverResourceId) } + binding
39 + save(updated)
40 + }
41 +
42 + fun remove(kind: String, serverResourceId: String?) {
43 + save(list().filterNot { it.matches(kind, serverResourceId) })
44 + }
45 +
46 + fun clear() {
47 + prefs.edit().clear().apply()
48 + }
49 +
50 + private fun save(bindings: List<CalendarBinding>) {
51 + prefs.edit().putString(KEY_BINDINGS, json.encodeToString(bindings)).apply()
52 + }
53 +
54 + private fun CalendarBinding.matches(kind: String, serverResourceId: String?): Boolean =
55 + this.kind == kind && this.serverResourceId == serverResourceId
56 +
57 + internal companion object {
58 + const val PREFS_NAME = "card2vcf_calendar_bindings"
59 + const val KEY_BINDINGS = "bindings"
60 + private val json = Json { ignoreUnknownKeys = true }
61 + }
62 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/CalendarBridge.kt
+181 -0
@@ -0,0 +1,181 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import android.content.ContentResolver
4 +import android.content.ContentUris
5 +import android.content.ContentValues
6 +import android.provider.CalendarContract
7 +import java.util.TimeZone
8 +
9 +/**
10 + * Encode/décode le `serverId` distant d'un événement dans `Events.DESCRIPTION`.
11 + *
12 + * Choix : préfixe de ligne stable plutôt qu'extended properties Calendar, dont la fiabilité varie
13 + * selon les fournisseurs de calendrier hors-GMS (certains ne les exposent pas via `ContentResolver`
14 + * sans compte de synchronisation). La description visible par l'utilisateur ([CalendarEventSnapshot.descriptionBody])
15 + * suit immédiatement la ligne de préfixe.
16 + */
17 +object CalendarServerIdCodec {
18 + private const val PREFIX = "card2vcf:serverId="
19 +
20 + fun encode(serverId: String?, descriptionBody: String): String {
21 + if (serverId == null) return descriptionBody
22 + return "$PREFIX$serverId\n$descriptionBody"
23 + }
24 +
25 + /** Retourne `(serverId, descriptionBody)` ; `serverId` est `null` si le préfixe est absent. */
26 + fun decode(description: String?): Pair<String?, String> {
27 + if (description == null) return null to ""
28 + if (!description.startsWith(PREFIX)) return null to description
29 +
30 + val rest = description.removePrefix(PREFIX)
31 + val newlineIndex = rest.indexOf('\n')
32 + return if (newlineIndex == -1) {
33 + rest to ""
34 + } else {
35 + rest.substring(0, newlineIndex) to rest.substring(newlineIndex + 1)
36 + }
37 + }
38 +}
39 +
40 +/** Préfixe de titre distinguant les indisponibilités (miroir lecture) des réservations/RDV. */
41 +object CalendarEventTitles {
42 + private const val INDISPO_PREFIX = "[Indispo] "
43 +
44 + fun indispoTitle(title: String): String = "$INDISPO_PREFIX$title"
45 +
46 + fun isIndispoTitle(title: String): Boolean = title.startsWith(INDISPO_PREFIX)
47 +
48 + fun stripIndispoPrefix(title: String): String = title.removePrefix(INDISPO_PREFIX)
49 +}
50 +
51 +/** Contrat pont Agenda utilisé par [SyncEngine]/[AgendaSyncCoordinator] ; [CalendarBridge] l'implémente, fake en test. */
52 +interface CalendarBridgeApi {
53 + fun ensureLocalCalendar(displayName: String): Long
54 +
55 + fun listEvents(calendarId: Long): List<CalendarEventSnapshot>
56 +
57 + fun upsertEvent(calendarId: Long, snap: CalendarEventSnapshot): Long
58 +
59 + fun deleteEvent(eventId: Long)
60 +}
61 +
62 +/**
63 + * Pont Room ↔ Agenda Android via des calendriers **locaux** (`ACCOUNT_TYPE_LOCAL`), pour rester
64 + * fonctionnel sans compte Google / services Play. Le `serverId` distant est encodé dans
65 + * `Events.DESCRIPTION` (voir [CalendarServerIdCodec]).
66 + */
67 +class CalendarBridge(private val resolver: ContentResolver) : CalendarBridgeApi {
68 +
69 + /** Crée le calendrier local `displayName` s'il n'existe pas encore, et retourne son id. */
70 + override fun ensureLocalCalendar(displayName: String): Long {
71 + findCalendarId(displayName)?.let { return it }
72 +
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()
78 +
79 + val values = ContentValues().apply {
80 + put(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT_NAME)
81 + put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
82 + put(CalendarContract.Calendars.NAME, displayName)
83 + put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, displayName)
84 + put(CalendarContract.Calendars.CALENDAR_COLOR, DEFAULT_COLOR)
85 + put(CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, CalendarContract.Calendars.CAL_ACCESS_OWNER)
86 + put(CalendarContract.Calendars.OWNER_ACCOUNT, ACCOUNT_NAME)
87 + put(CalendarContract.Calendars.VISIBLE, 1)
88 + put(CalendarContract.Calendars.SYNC_EVENTS, 1)
89 + }
90 +
91 + val created = resolver.insert(uri, values)
92 + ?: error("Échec de création du calendrier local « $displayName »")
93 + return ContentUris.parseId(created)
94 + }
95 +
96 + override fun listEvents(calendarId: Long): List<CalendarEventSnapshot> {
97 + val projection = arrayOf(
98 + CalendarContract.Events._ID,
99 + CalendarContract.Events.TITLE,
100 + CalendarContract.Events.DESCRIPTION,
101 + CalendarContract.Events.DTSTART,
102 + CalendarContract.Events.DTEND,
103 + )
104 + val events = mutableListOf<CalendarEventSnapshot>()
105 + resolver.query(
106 + CalendarContract.Events.CONTENT_URI,
107 + projection,
108 + "${CalendarContract.Events.CALENDAR_ID} = ?",
109 + arrayOf(calendarId.toString()),
110 + null,
111 + )?.use { cursor ->
112 + val idIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events._ID)
113 + val titleIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events.TITLE)
114 + val descIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events.DESCRIPTION)
115 + val startIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events.DTSTART)
116 + val endIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events.DTEND)
117 + while (cursor.moveToNext()) {
118 + val (serverId, body) = CalendarServerIdCodec.decode(cursor.getString(descIdx))
119 + events += CalendarEventSnapshot(
120 + title = cursor.getString(titleIdx) ?: "",
121 + debutMs = cursor.getLong(startIdx),
122 + finMs = cursor.getLong(endIdx),
123 + eventId = cursor.getLong(idIdx),
124 + serverId = serverId,
125 + descriptionBody = body,
126 + )
127 + }
128 + }
129 + return events
130 + }
131 +
132 + /** Crée l'événement (si `snap.eventId == null`) ou le met à jour, et retourne son id. */
133 + override fun upsertEvent(calendarId: Long, snap: CalendarEventSnapshot): Long {
134 + val values = ContentValues().apply {
135 + put(CalendarContract.Events.CALENDAR_ID, calendarId)
136 + put(CalendarContract.Events.TITLE, snap.title)
137 + put(CalendarContract.Events.DESCRIPTION, CalendarServerIdCodec.encode(snap.serverId, snap.descriptionBody))
138 + put(CalendarContract.Events.DTSTART, snap.debutMs)
139 + put(CalendarContract.Events.DTEND, snap.finMs)
140 + put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id)
141 + }
142 +
143 + val existingEventId = snap.eventId
144 + if (existingEventId != null) {
145 + val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, existingEventId)
146 + resolver.update(uri, values, null, null)
147 + return existingEventId
148 + }
149 +
150 + val created = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
151 + ?: error("Échec de création de l'événement « ${snap.title} »")
152 + return ContentUris.parseId(created)
153 + }
154 +
155 + override fun deleteEvent(eventId: Long) {
156 + val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
157 + resolver.delete(uri, null, null)
158 + }
159 +
160 + private fun findCalendarId(displayName: String): Long? {
161 + val projection = arrayOf(CalendarContract.Calendars._ID)
162 + val selection = "${CalendarContract.Calendars.ACCOUNT_NAME} = ? AND " +
163 + "${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " +
164 + "${CalendarContract.Calendars.CALENDAR_DISPLAY_NAME} = ?"
165 + val args = arrayOf(ACCOUNT_NAME, CalendarContract.ACCOUNT_TYPE_LOCAL, displayName)
166 +
167 + return resolver.query(CalendarContract.Calendars.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
168 + if (cursor.moveToFirst()) {
169 + cursor.getLong(cursor.getColumnIndexOrThrow(CalendarContract.Calendars._ID))
170 + } else {
171 + null
172 + }
173 + }
174 + }
175 +
176 + companion object {
177 + /** Compte local dédié Card2vcf, hors-GMS : pas de synchronisation cloud. */
178 + const val ACCOUNT_NAME = "card2vcf"
179 + private const val DEFAULT_COLOR = 0xFF000000.toInt()
180 + }
181 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/CalendarEventSnapshot.kt
+11 -0
@@ -0,0 +1,11 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +/** Miroir léger d'un événement `CalendarContract.Events`, indépendant du `ContentResolver`. */
4 +data class CalendarEventSnapshot(
5 + val title: String,
6 + val debutMs: Long,
7 + val finMs: Long,
8 + val eventId: Long? = null,
9 + val serverId: String? = null,
10 + val descriptionBody: String = "",
11 +)
M android/app/src/main/java/fr/ebii/card2vcf/sync/SyncEngine.kt
+159 -28
@@ -22,15 +22,23 @@ data class SyncResult(
22 22 val success: Boolean,
23 23 val serverTime: String? = null,
24 24 val error: String? = null,
25 + val conflicts: List<AgendaConflict> = emptyList(),
25 26 )
26 27
27 -/** Pousse la file [SyncOpEntity] puis tire `/api/sync/pull` ; LWW / append / tombstones. */
28 +/**
29 + * Pousse la file [SyncOpEntity] puis tire `/api/sync/pull` ; LWW / append / tombstones.
30 + * Le pont Agenda↔Room (RDV + réservations/indispos liés à des calendriers Android) est délégué à
31 + * [AgendaSyncCoordinator] ; ce moteur orchestre l'ordre des étapes et le push (y compris le cas
32 + * particulier 409 sur les réservations).
33 + */
28 34 class SyncEngine(
29 35 private val api: AilianceApi,
30 36 private val db: CrmDatabase,
31 37 ) {
32 - suspend fun checkStatus(): SyncStatusResult = withContext(Dispatchers.IO) {
33 - when (val result = api.syncStatus(watermark())) {
38 + private val agenda = AgendaSyncCoordinator(db)
39 +
40 + suspend fun checkStatus(ressourcesQuery: String? = null): SyncStatusResult = withContext(Dispatchers.IO) {
41 + when (val result = api.syncStatus(watermark(), ressourcesQuery)) {
34 42 is AilianceApiClient.ApiResult.Ok ->
35 43 SyncStatusResult(pendingRemoteChanges = result.value.total, response = result.value)
36 44 is AilianceApiClient.ApiResult.Err ->
@@ -38,18 +46,56 @@ class SyncEngine(
38 46 }
39 47 }
40 48
41 - suspend fun syncNow(): SyncResult = withContext(Dispatchers.IO) {
42 - pushOps()
43 - when (val result = api.syncPull(watermark())) {
49 + /**
50 + * [bindings]/[bridge] vides ou `null` : chemin CRM v1 inchangé (pas de pont Agenda, `ressources=` omis).
51 + */
52 + suspend fun syncNow(
53 + bindings: List<CalendarBinding> = emptyList(),
54 + bridge: CalendarBridgeApi? = null,
55 + ): SyncResult = withContext(Dispatchers.IO) {
56 + val agendaEnabled = bridge != null && bindings.isNotEmpty()
57 + if (agendaEnabled) agenda.agendaToRoom(bindings, bridge!!)
58 +
59 + val conflicts = pushOps()
60 + val ressourcesQuery = if (agendaEnabled) agenda.ressourcesQuery(bindings) else null
61 +
62 + when (val result = api.syncPull(watermark(), ressourcesQuery)) {
44 63 is AilianceApiClient.ApiResult.Ok -> {
45 64 applyPull(result.value)
65 + if (agendaEnabled) agenda.roomToAgenda(bindings, bridge!!)
46 66 setWatermark(result.value.serverTime)
47 - SyncResult(success = true, serverTime = result.value.serverTime)
67 + SyncResult(success = true, serverTime = result.value.serverTime, conflicts = conflicts)
48 68 }
49 - is AilianceApiClient.ApiResult.Err -> SyncResult(success = false, error = result.message)
69 + is AilianceApiClient.ApiResult.Err -> SyncResult(success = false, error = result.message, conflicts = conflicts)
50 70 }
51 71 }
52 72
73 + /**
74 + * Nombre d'éléments locaux non encore poussés (bandeau « calendrier local en avance »).
75 + * Avec [bindings]/[bridge] : ajoute les events Agenda card2vcf-tagués sans entité Room
76 + * (orphelins pas encore nettoyés côté Agenda, voir [AgendaSyncCoordinator.orphanEventCount]).
77 + */
78 + suspend fun localAheadCount(
79 + bindings: List<CalendarBinding> = emptyList(),
80 + bridge: CalendarBridgeApi? = null,
81 + ): Int = withContext(Dispatchers.IO) {
82 + val dirtyRdv = db.rdvDao().listDirty().size
83 + val dirtyReservations = db.reservationDao().listDirty().size
84 + val pendingAgendaOps = db.syncOpDao().listAll().count { it.entityType == AgendaSyncCoordinator.KIND_RDV || it.entityType == AgendaSyncCoordinator.ENTITY_RESERVATION }
85 + val orphanEvents = if (bridge != null && bindings.isNotEmpty()) agenda.orphanEventCount(bindings, bridge) else 0
86 + dirtyRdv + dirtyReservations + pendingAgendaOps + orphanEvents
87 + }
88 +
89 + /** L'utilisateur renonce à sa réservation en conflit : purge entité + SyncOp + event Agenda. */
90 + suspend fun abandonLocalReservation(localId: Long, bridge: CalendarBridgeApi? = null) = withContext(Dispatchers.IO) {
91 + val reservation = db.reservationDao().getByLocalId(localId) ?: return@withContext
92 + reservation.calendarEventId?.let { bridge?.deleteEvent(it) }
93 + db.syncOpDao().listAll()
94 + .filter { it.entityType == AgendaSyncCoordinator.ENTITY_RESERVATION && it.localId == localId }
95 + .forEach { db.syncOpDao().deleteById(it.id) }
96 + db.reservationDao().deleteByLocalId(localId)
97 + }
98 +
53 99 // ---- watermark ----
54 100
55 101 private suspend fun watermark(): String =
@@ -61,18 +107,26 @@ class SyncEngine(
61 107
62 108 // ---- push ----
63 109
64 - private suspend fun pushOps() {
110 + /** Résultat d'une op poussée : [conflict] non nul uniquement pour un 409 réservation ([ok] reste `false`). */
111 + private data class PushOutcome(val ok: Boolean, val conflict: AgendaConflict? = null)
112 +
113 + private suspend fun pushOps(): List<AgendaConflict> {
114 + val conflicts = mutableListOf<AgendaConflict>()
65 115 for (op in db.syncOpDao().listAll()) {
66 - val ok = when (op.entityType) {
67 - "contact" -> pushContactOp(op)
68 - "entreprise" -> pushEntrepriseOp(op)
69 - "projet" -> pushProjetOp(op)
70 - "tache" -> pushTacheOp(op)
71 - "interaction" -> pushInteractionOp(op)
72 - else -> true
116 + val outcome = when (op.entityType) {
117 + "contact" -> PushOutcome(pushContactOp(op))
118 + "entreprise" -> PushOutcome(pushEntrepriseOp(op))
119 + "projet" -> PushOutcome(pushProjetOp(op))
120 + "tache" -> PushOutcome(pushTacheOp(op))
121 + "interaction" -> PushOutcome(pushInteractionOp(op))
122 + AgendaSyncCoordinator.KIND_RDV -> PushOutcome(pushRdvOp(op))
123 + AgendaSyncCoordinator.ENTITY_RESERVATION -> pushReservationOp(op)
124 + else -> PushOutcome(true)
73 125 }
74 - if (ok) db.syncOpDao().deleteById(op.id)
126 + if (outcome.ok) db.syncOpDao().deleteById(op.id)
127 + outcome.conflict?.let(conflicts::add)
75 128 }
129 + return conflicts
76 130 }
77 131
78 132 private suspend fun pushContactOp(op: SyncOpEntity): Boolean = when (op.op) {
@@ -165,6 +219,82 @@ class SyncEngine(
165 219 return api.createInteraction(contactServerId, op.payloadJson).isOk()
166 220 }
167 221
222 + private suspend fun pushRdvOp(op: SyncOpEntity): Boolean = when (op.op) {
223 + "create" -> {
224 + val result = api.createRdv(op.payloadJson)
225 + if (result is AilianceApiClient.ApiResult.Ok) {
226 + val newServerId = parseCreatedId(result.value)
227 + if (newServerId != null && op.localId != null) {
228 + db.rdvDao().getByLocalId(op.localId)?.let {
229 + db.rdvDao().upsert(it.copy(serverId = newServerId, dirtyLocal = false))
230 + }
231 + }
232 + }
233 + result is AilianceApiClient.ApiResult.Ok
234 + }
235 + "update" -> op.serverId?.let { serverId ->
236 + val ok = api.updateRdv(serverId, op.payloadJson).isOk()
237 + if (ok && op.localId != null) {
238 + db.rdvDao().getByLocalId(op.localId)?.let { db.rdvDao().upsert(it.copy(dirtyLocal = false)) }
239 + }
240 + ok
241 + } ?: false
242 + "delete" -> op.serverId?.let { api.deleteRdv(it).isOk() } ?: false
243 + else -> true
244 + }
245 +
246 + /**
247 + * Résout `(cibleType, cibleId)` via [op.localId] (create) ou [op.serverId] (update/delete), avec
248 + * repli sur `op.payloadJson` (un `delete` détecté après suppression Agenda supprime déjà l'entité
249 + * locale ; voir `AgendaSyncCoordinator.cibleRefPayload`).
250 + */
251 + private suspend fun resolveReservationCible(op: SyncOpEntity): Pair<String, String>? {
252 + op.localId?.let { db.reservationDao().getByLocalId(it)?.let { r -> return r.cibleType to r.cibleId } }
253 + op.serverId?.let { db.reservationDao().getByServerId(it)?.let { r -> return r.cibleType to r.cibleId } }
254 + return parseCibleRef(op.payloadJson)
255 + }
256 +
257 + /**
258 + * Sur 409 (chevauchement serveur) : ne pas dépiler l'op (voir [pushOps]), marquer `conflictPending`
259 + * sur l'entité locale et remonter un [AgendaConflict] pour la dialog « Annuler ma réservation ? ».
260 + */
261 + private suspend fun pushReservationOp(op: SyncOpEntity): PushOutcome {
262 + val (cibleType, cibleId) = resolveReservationCible(op) ?: return PushOutcome(false)
263 + return when (op.op) {
264 + "create" -> handleReservationPushResult(api.createReservation(cibleType, cibleId, op.payloadJson), op)
265 + "update" -> op.serverId?.let { serverId ->
266 + handleReservationPushResult(api.updateReservation(cibleType, cibleId, serverId, op.payloadJson), op)
267 + } ?: PushOutcome(false)
268 + "delete" -> op.serverId?.let { PushOutcome(api.deleteReservation(cibleType, cibleId, it).isOk()) } ?: PushOutcome(false)
269 + else -> PushOutcome(true)
270 + }
271 + }
272 +
273 + private suspend fun handleReservationPushResult(
274 + result: AilianceApiClient.ApiResult<String>,
275 + op: SyncOpEntity,
276 + ): PushOutcome = when (result) {
277 + is AilianceApiClient.ApiResult.Ok -> {
278 + if (op.localId != null) {
279 + db.reservationDao().getByLocalId(op.localId)?.let { local ->
280 + val serverId = if (op.op == "create") parseCreatedId(result.value) ?: local.serverId else local.serverId
281 + db.reservationDao().upsert(local.copy(serverId = serverId, dirtyLocal = false))
282 + }
283 + }
284 + PushOutcome(true)
285 + }
286 + is AilianceApiClient.ApiResult.Err -> {
287 + if (result.code == 409 && op.localId != null) {
288 + db.reservationDao().getByLocalId(op.localId)?.let { local ->
289 + db.reservationDao().upsert(local.copy(conflictPending = true))
290 + }
291 + PushOutcome(false, AgendaConflict(localReservationId = op.localId, message = result.message))
292 + } else {
293 + PushOutcome(false)
294 + }
295 + }
296 + }
297 +
168 298 // ---- pull ----
169 299
170 300 private suspend fun applyPull(pull: SyncPullResponse) {
@@ -174,6 +304,9 @@ class SyncEngine(
174 304 pull.taches.forEach { applyTache(it) }
175 305 pull.interactions.forEach { applyInteraction(it) }
176 306 pull.workflows.forEach { applyWorkflow(it) }
307 + pull.rdv.forEach { agenda.applyRdvPull(it) }
308 + pull.reservations.forEach { agenda.applyReservationPull(it) }
309 + pull.indisponibilites.forEach { agenda.applyIndisponibilitePull(it) }
177 310 pull.tombstones.forEach { applyTombstone(it) }
178 311 }
179 312
@@ -343,6 +476,7 @@ class SyncEngine(
343 476 }
344 477 "tache" -> db.tacheDao().deleteByServerId(dto.id)
345 478 "interaction" -> db.interactionDao().deleteByServerId(dto.id)
479 + else -> agenda.applyAgendaTombstone(dto)
346 480 }
347 481 }
348 482
@@ -352,17 +486,6 @@ class SyncEngine(
352 486
353 487 fun <T> AilianceApiClient.ApiResult<T>.isOk(): Boolean = this is AilianceApiClient.ApiResult.Ok
354 488
355 - fun parseIsoToEpochMs(iso: String?): Long =
356 - if (iso == null) {
357 - 0L
358 - } else {
359 - try {
360 - java.time.Instant.parse(iso).toEpochMilli()
361 - } catch (_: Exception) {
362 - 0L
363 - }
364 - }
365 -
366 489 @Serializable
367 490 data class CreatedIdDto(val id: String? = null)
368 491
@@ -372,5 +495,13 @@ class SyncEngine(
372 495 } catch (_: Exception) {
373 496 null
374 497 }
498 +
499 + fun parseCibleRef(payloadJson: String): Pair<String, String>? =
500 + try {
501 + val ref = syncJson.decodeFromString(CibleRessourceDto.serializer(), payloadJson)
502 + ref.type to ref.id
503 + } catch (_: Exception) {
504 + null
505 + }
375 506 }
376 507 }
M android/app/src/main/java/fr/ebii/card2vcf/sync/SyncModels.kt
+99 -0
@@ -40,6 +40,9 @@ data class SyncChanges(
40 40 val projets: Int = 0,
41 41 val taches: Int = 0,
42 42 val interactions: Int = 0,
43 + val rdv: Int = 0,
44 + val reservations: Int = 0,
45 + val indisponibilites: Int = 0,
43 46 val tombstones: Int = 0,
44 47 )
45 48
@@ -58,6 +61,9 @@ data class SyncPullResponse(
58 61 val projets: List<ProjetDto> = emptyList(),
59 62 val taches: List<TacheSyncDto> = emptyList(),
60 63 val interactions: List<InteractionDto> = emptyList(),
64 + val rdv: List<RendezVousDto> = emptyList(),
65 + val reservations: List<ReservationDto> = emptyList(),
66 + val indisponibilites: List<IndisponibiliteDto> = emptyList(),
61 67 val tombstones: List<TombstoneDto> = emptyList(),
62 68 val workflows: List<WorkflowDto> = emptyList(),
63 69 )
@@ -67,6 +73,9 @@ data class TombstoneDto(
67 73 @SerialName("type") val entityType: String,
68 74 val id: String,
69 75 val projetId: String? = null,
76 + val cibleType: String? = null,
77 + val cibleId: String? = null,
78 + val utilisateur: String? = null,
70 79 val supprimeLe: String,
71 80 )
72 81
@@ -149,6 +158,74 @@ data class ColonneDto(
149 158 val ordre: Int = 0,
150 159 )
151 160
161 +/** Cible d'une réservation/indisponibilité (miroir enum Rust `CibleRessource`, tag `type` + `id`). */
162 +@Serializable
163 +data class CibleRessourceDto(
164 + val type: String,
165 + val id: String,
166 +)
167 +
168 +@Serializable
169 +data class RendezVousDto(
170 + val id: String,
171 + val titre: String = "",
172 + val description: String = "",
173 + val utilisateur: String = "",
174 + val contactIds: List<String> = emptyList(),
175 + val lieu: String = "",
176 + val debut: String,
177 + val fin: String,
178 + val projetId: String? = null,
179 + val creePar: String = "",
180 + val creeLe: String,
181 + val misAJourLe: String? = null,
182 +)
183 +
184 +@Serializable
185 +data class ReservationDto(
186 + val id: String,
187 + val cible: CibleRessourceDto,
188 + val debut: String,
189 + val fin: String,
190 + val motif: String = "",
191 + val projetId: String? = null,
192 + val rdvId: String? = null,
193 + val statut: String = "active",
194 + val reservePar: String = "",
195 + val creeLe: String,
196 + val misAJourLe: String? = null,
197 +)
198 +
199 +@Serializable
200 +data class IndisponibiliteDto(
201 + val id: String,
202 + val cible: CibleRessourceDto,
203 + val nature: String,
204 + val debut: String,
205 + val fin: String,
206 + val commentaire: String = "",
207 + val creePar: String = "",
208 + val creeLe: String,
209 + val misAJourLe: String? = null,
210 +)
211 +
212 +/** Ressource catalogue allégée (salle/matériel/véhicule) : champs communs uniquement. */
213 +@Serializable
214 +data class RessourceItemDto(
215 + val id: String,
216 + val nom: String,
217 + val description: String = "",
218 + val lieu: String = "",
219 + val actif: Boolean = true,
220 +)
221 +
222 +@Serializable
223 +data class RessourcesCatalogueDto(
224 + val salles: List<RessourceItemDto> = emptyList(),
225 + val materiels: List<RessourceItemDto> = emptyList(),
226 + val vehicules: List<RessourceItemDto> = emptyList(),
227 +)
228 +
152 229 @Serializable
153 230 data class WorkflowDto(
154 231 val id: String,
@@ -208,3 +285,25 @@ data class CreateContactRequest(
208 285 val etape: String = "nouveau",
209 286 val tags: List<String> = emptyList(),
210 287 )
288 +
289 +/** Payload create/update RDV (miroir serveur, body `/api/rdv`). */
290 +@Serializable
291 +data class RdvUpsertRequest(
292 + val titre: String = "",
293 + val description: String = "",
294 + val lieu: String = "",
295 + val debut: String,
296 + val fin: String,
297 + val contactIds: List<String> = emptyList(),
298 + val projetId: String? = null,
299 +)
300 +
301 +/** Payload create/update réservation (miroir serveur, body `/api/ressources/:genre/:id/reservations`). */
302 +@Serializable
303 +data class ReservationUpsertRequest(
304 + val debut: String,
305 + val fin: String,
306 + val motif: String = "",
307 + val projetId: String? = null,
308 + val rdvId: String? = null,
309 +)
A android/app/src/main/java/fr/ebii/card2vcf/sync/SyncTimeUtils.kt
+16 -0
@@ -0,0 +1,16 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +/** ISO-8601 UTC → epoch ms, tolérant aux valeurs absentes/invalides (miroir horodatages serveur `mis_a_jour_le`). */
4 +internal fun parseIsoToEpochMs(iso: String?): Long =
5 + if (iso == null) {
6 + 0L
7 + } else {
8 + try {
9 + java.time.Instant.parse(iso).toEpochMilli()
10 + } catch (_: Exception) {
11 + 0L
12 + }
13 + }
14 +
15 +/** epoch ms → ISO-8601 UTC, pour les payloads poussés au serveur (`debut`/`fin`). */
16 +internal fun epochMsToIso(epochMs: Long): String = java.time.Instant.ofEpochMilli(epochMs).toString()
M android/app/src/main/java/fr/ebii/card2vcf/ui/carnet/CarnetScreen.kt
+10 -0
@@ -58,6 +58,7 @@ import fr.ebii.card2vcf.ui.nav.MainTab
58 58 import fr.ebii.card2vcf.ui.nav.MainTabRow
59 59 import fr.ebii.card2vcf.ui.sync.SyncBanner
60 60 import fr.ebii.card2vcf.ui.sync.SyncChromeViewModel
61 +import fr.ebii.card2vcf.ui.sync.SyncConflictDialog
61 62 import fr.ebii.card2vcf.ui.theme.Bordure
62 63 import fr.ebii.card2vcf.ui.theme.Fond
63 64 import fr.ebii.card2vcf.ui.theme.Ink
@@ -92,8 +93,10 @@ fun CarnetScreen(
92 93 val isEmpty = sections.isEmpty() || sections.all { it.second.isEmpty() }
93 94 val syncConfigured by syncViewModel.configured.collectAsState()
94 95 val pendingRemoteChanges by syncViewModel.pendingRemoteChanges.collectAsState()
96 + val localCalendarAhead by syncViewModel.localCalendarAhead.collectAsState()
95 97 val syncing by syncViewModel.syncing.collectAsState()
96 98 val syncError by syncViewModel.error.collectAsState()
99 + val syncConflict by syncViewModel.conflictPending.collectAsState()
97 100
98 101 LaunchedEffect(Unit) {
99 102 syncViewModel.refreshStatus()
@@ -242,11 +245,18 @@ fun CarnetScreen(
242 245
243 246 SyncBanner(
244 247 pendingChanges = pendingRemoteChanges,
248 + localAhead = localCalendarAhead,
245 249 syncing = syncing,
246 250 error = syncError,
247 251 onSyncClick = { syncViewModel.syncNow() },
248 252 )
249 253
254 + SyncConflictDialog(
255 + conflict = syncConflict,
256 + onConfirm = { syncViewModel.confirmConflictCancellation() },
257 + onDismiss = { syncViewModel.dismissConflict() },
258 + )
259 +
250 260 Spacer(Modifier.height(8.dp))
251 261
252 262 if (isEmpty) {
M android/app/src/main/java/fr/ebii/card2vcf/ui/nav/Card2vcfNavHost.kt
+10 -2
@@ -23,6 +23,8 @@ import fr.ebii.card2vcf.data.CrmDatabase
23 23 import fr.ebii.card2vcf.ocr.TesseractOcrEngine
24 24 import fr.ebii.card2vcf.scan.OpenCvScanEngine
25 25 import fr.ebii.card2vcf.sync.AilianceApiClient
26 +import fr.ebii.card2vcf.sync.CalendarBindingsStore
27 +import fr.ebii.card2vcf.sync.CalendarBridge
26 28 import fr.ebii.card2vcf.sync.SyncCredentialsStore
27 29 import fr.ebii.card2vcf.sync.SyncEngine
28 30 import fr.ebii.card2vcf.ui.ScanCarteScreen
@@ -93,11 +95,17 @@ fun Card2vcfNavHost(
93 95 }
94 96 val scope = rememberCoroutineScope()
95 97 val credentialsStore = remember { SyncCredentialsStore(context.applicationContext) }
96 - val settingsVm = remember(credentialsStore) { SettingsViewModel(credentialsStore) }
98 + val calendarBindingsStore = remember { CalendarBindingsStore(context.applicationContext) }
99 + val calendarBridge = remember { CalendarBridge(context.applicationContext.contentResolver) }
100 + val settingsVm = remember(credentialsStore, calendarBindingsStore, calendarBridge) {
101 + SettingsViewModel(credentialsStore, calendarBindingsStore, calendarBridge)
102 + }
97 103 val projetsVm = remember(database) { ProjetsViewModel(database) }
98 - val syncChromeVm = remember(credentialsStore, database) {
104 + val syncChromeVm = remember(credentialsStore, calendarBindingsStore, calendarBridge, database) {
99 105 SyncChromeViewModel(
100 106 credentialsStore = credentialsStore,
107 + calendarBindingsStore = calendarBindingsStore,
108 + calendarBridge = calendarBridge,
101 109 engineFactory = {
102 110 val baseUrl = credentialsStore.baseUrl
103 111 val apiKey = credentialsStore.apiKey
M android/app/src/main/java/fr/ebii/card2vcf/ui/projets/ProjetsListScreen.kt
+10 -0
@@ -35,6 +35,7 @@ import fr.ebii.card2vcf.ui.nav.MainTab
35 35 import fr.ebii.card2vcf.ui.nav.MainTabRow
36 36 import fr.ebii.card2vcf.ui.sync.SyncBanner
37 37 import fr.ebii.card2vcf.ui.sync.SyncChromeViewModel
38 +import fr.ebii.card2vcf.ui.sync.SyncConflictDialog
38 39 import fr.ebii.card2vcf.ui.theme.Bordure
39 40 import fr.ebii.card2vcf.ui.theme.Fond
40 41 import fr.ebii.card2vcf.ui.theme.Ink
@@ -52,8 +53,10 @@ fun ProjetsListScreen(
52 53 val projets by viewModel.projets.collectAsState()
53 54 val syncConfigured by syncViewModel.configured.collectAsState()
54 55 val pendingRemoteChanges by syncViewModel.pendingRemoteChanges.collectAsState()
56 + val localCalendarAhead by syncViewModel.localCalendarAhead.collectAsState()
55 57 val syncing by syncViewModel.syncing.collectAsState()
56 58 val syncError by syncViewModel.error.collectAsState()
59 + val syncConflict by syncViewModel.conflictPending.collectAsState()
57 60
58 61 LaunchedEffect(Unit) {
59 62 syncViewModel.refreshStatus()
@@ -90,11 +93,18 @@ fun ProjetsListScreen(
90 93
91 94 SyncBanner(
92 95 pendingChanges = pendingRemoteChanges,
96 + localAhead = localCalendarAhead,
93 97 syncing = syncing,
94 98 error = syncError,
95 99 onSyncClick = { syncViewModel.syncNow() },
96 100 )
97 101
102 + SyncConflictDialog(
103 + conflict = syncConflict,
104 + onConfirm = { syncViewModel.confirmConflictCancellation() },
105 + onDismiss = { syncViewModel.dismissConflict() },
106 + )
107 +
98 108 Spacer(Modifier.height(8.dp))
99 109
100 110 if (projets.isEmpty()) {
M android/app/src/main/java/fr/ebii/card2vcf/ui/settings/SettingsScreen.kt
+168 -3
@@ -1,9 +1,15 @@
1 1 package fr.ebii.card2vcf.ui.settings
2 2
3 +import android.Manifest
4 +import android.content.Context
5 +import android.content.pm.PackageManager
6 +import androidx.activity.compose.rememberLauncherForActivityResult
7 +import androidx.activity.result.contract.ActivityResultContracts
3 8 import androidx.compose.foundation.background
4 9 import androidx.compose.foundation.layout.Arrangement
5 10 import androidx.compose.foundation.layout.Box
6 11 import androidx.compose.foundation.layout.Column
12 +import androidx.compose.foundation.layout.Row
7 13 import androidx.compose.foundation.layout.fillMaxSize
8 14 import androidx.compose.foundation.layout.fillMaxWidth
9 15 import androidx.compose.foundation.layout.padding
@@ -13,21 +19,31 @@ import androidx.compose.material.icons.automirrored.outlined.ArrowBack
13 19 import androidx.compose.material3.AlertDialog
14 20 import androidx.compose.material3.Button
15 21 import androidx.compose.material3.ButtonDefaults
22 +import androidx.compose.material3.HorizontalDivider
16 23 import androidx.compose.material3.Icon
17 24 import androidx.compose.material3.IconButton
18 25 import androidx.compose.material3.MaterialTheme
26 +import androidx.compose.material3.OutlinedButton
19 27 import androidx.compose.material3.OutlinedTextField
20 28 import androidx.compose.material3.OutlinedTextFieldDefaults
29 +import androidx.compose.material3.Switch
30 +import androidx.compose.material3.SwitchDefaults
21 31 import androidx.compose.material3.Text
22 32 import androidx.compose.material3.TextButton
23 33 import androidx.compose.runtime.Composable
34 +import androidx.compose.runtime.LaunchedEffect
24 35 import androidx.compose.runtime.collectAsState
25 36 import androidx.compose.runtime.getValue
37 +import androidx.compose.runtime.mutableStateOf
38 +import androidx.compose.runtime.remember
39 +import androidx.compose.runtime.setValue
26 40 import androidx.compose.ui.Alignment
27 41 import androidx.compose.ui.Modifier
42 +import androidx.compose.ui.platform.LocalContext
28 43 import androidx.compose.ui.res.stringResource
29 44 import androidx.compose.ui.text.input.PasswordVisualTransformation
30 45 import androidx.compose.ui.unit.dp
46 +import androidx.core.content.ContextCompat
31 47 import fr.ebii.card2vcf.R
32 48 import fr.ebii.card2vcf.ui.theme.Bordure
33 49 import fr.ebii.card2vcf.ui.theme.Fond
@@ -37,6 +53,10 @@ import fr.ebii.card2vcf.ui.theme.Surface
37 53 import fr.ebii.card2vcf.ui.theme.TexteFaible
38 54
39 55 private val Square = RoundedCornerShape(0.dp)
56 +private val CalendarPermissions = arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
57 +
58 +private fun hasCalendarPermissions(context: Context): Boolean =
59 + CalendarPermissions.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED }
40 60
41 61 @Composable
42 62 fun SettingsScreen(
@@ -45,6 +65,17 @@ fun SettingsScreen(
45 65 modifier: Modifier = Modifier,
46 66 ) {
47 67 val state by viewModel.state.collectAsState()
68 + val context = LocalContext.current
69 + var hasCalendarPermission by remember { mutableStateOf(hasCalendarPermissions(context)) }
70 + val calendarPermissionLauncher = rememberLauncherForActivityResult(
71 + ActivityResultContracts.RequestMultiplePermissions(),
72 + ) { results ->
73 + hasCalendarPermission = results.values.all { it }
74 + }
75 +
76 + LaunchedEffect(hasCalendarPermission) {
77 + viewModel.onCalendarPermissionChanged(hasCalendarPermission)
78 + }
48 79
49 80 Column(modifier.fillMaxSize().background(Fond)) {
50 81 Box(
@@ -67,7 +98,9 @@ fun SettingsScreen(
67 98 when (state) {
68 99 is SettingsUiState.LoggedIn -> LoggedInContent(
69 100 state = state as SettingsUiState.LoggedIn,
70 - onDisconnect = viewModel::disconnect,
101 + viewModel = viewModel,
102 + hasCalendarPermission = hasCalendarPermission,
103 + onRequestCalendarPermission = { calendarPermissionLauncher.launch(CalendarPermissions) },
71 104 )
72 105 is SettingsUiState.LoggedOut -> LoggedOutContent(
73 106 state = state as SettingsUiState.LoggedOut,
@@ -80,7 +113,9 @@ fun SettingsScreen(
80 113 @Composable
81 114 private fun LoggedInContent(
82 115 state: SettingsUiState.LoggedIn,
83 - onDisconnect: () -> Unit,
116 + viewModel: SettingsViewModel,
117 + hasCalendarPermission: Boolean,
118 + onRequestCalendarPermission: () -> Unit,
84 119 ) {
85 120 Column(
86 121 Modifier.fillMaxSize().padding(18.dp),
@@ -93,9 +128,139 @@ private fun LoggedInContent(
93 128 )
94 129 Text(state.baseUrl, color = TexteFaible)
95 130 Text(stringResource(R.string.settings_cle_configuree), color = TexteFaible)
96 - TextButton(onClick = onDisconnect, shape = Square) {
131 + TextButton(onClick = viewModel::disconnect, shape = Square) {
97 132 Text(stringResource(R.string.settings_deconnecter), color = Ink)
98 133 }
134 +
135 + HorizontalDivider(color = Bordure, thickness = 1.dp)
136 +
137 + CalendarSection(
138 + state = state,
139 + viewModel = viewModel,
140 + hasCalendarPermission = hasCalendarPermission,
141 + onRequestCalendarPermission = onRequestCalendarPermission,
142 + )
143 + }
144 +
145 + if (state.pendingRemoval != null) {
146 + AlertDialog(
147 + onDismissRequest = viewModel::dismissRemoveBinding,
148 + shape = Square,
149 + title = { Text(stringResource(R.string.settings_calendrier_retirer_titre), color = Ink) },
150 + text = { Text(stringResource(R.string.settings_calendrier_retirer_message, state.pendingRemoval.displayName), color = Ink) },
151 + confirmButton = {
152 + TextButton(onClick = viewModel::confirmRemoveBinding, shape = Square) {
153 + Text(stringResource(R.string.settings_calendrier_retirer_confirmer), color = Ink)
154 + }
155 + },
156 + dismissButton = {
157 + TextButton(onClick = viewModel::dismissRemoveBinding, shape = Square) {
158 + Text(stringResource(R.string.settings_calendrier_retirer_annuler), color = Ink)
159 + }
160 + },
161 + containerColor = Fond,
162 + )
163 + }
164 +}
165 +
166 +@Composable
167 +private fun CalendarSection(
168 + state: SettingsUiState.LoggedIn,
169 + viewModel: SettingsViewModel,
170 + hasCalendarPermission: Boolean,
171 + onRequestCalendarPermission: () -> Unit,
172 +) {
173 + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
174 + Text(
175 + stringResource(R.string.settings_calendriers_titre),
176 + style = MaterialTheme.typography.titleSmall,
177 + color = Ink,
178 + )
179 +
180 + if (!hasCalendarPermission) {
181 + Text(stringResource(R.string.settings_calendrier_permission_manquante), color = TexteFaible)
182 + OutlinedButton(onClick = onRequestCalendarPermission, shape = Square) {
183 + Text(stringResource(R.string.settings_calendrier_autoriser))
184 + }
185 + return
186 + }
187 +
188 + ToggleRow(
189 + label = stringResource(R.string.settings_calendrier_mes_rdv),
190 + checked = state.mesRdvLinked,
191 + enabled = true,
192 + onCheckedChange = viewModel::setMesRdvEnabled,
193 + )
194 +
195 + when {
196 + state.catalogueLoading -> Text(stringResource(R.string.settings_calendrier_chargement), color = TexteFaible)
197 + state.catalogueError != null -> Text(state.catalogueError, color = Ink)
198 + else -> {
199 + RessourceGroup(
200 + titleRes = R.string.settings_calendrier_salles,
201 + items = state.ressources.filter { it.kind == SettingsViewModel.KIND_SALLE },
202 + onToggle = viewModel::setRessourceEnabled,
203 + )
204 + RessourceGroup(
205 + titleRes = R.string.settings_calendrier_materiels,
206 + items = state.ressources.filter { it.kind == SettingsViewModel.KIND_MATERIEL },
207 + onToggle = viewModel::setRessourceEnabled,
208 + )
209 + RessourceGroup(
210 + titleRes = R.string.settings_calendrier_vehicules,
211 + items = state.ressources.filter { it.kind == SettingsViewModel.KIND_VEHICULE },
212 + onToggle = viewModel::setRessourceEnabled,
213 + )
214 + }
215 + }
216 + }
217 +}
218 +
219 +@Composable
220 +private fun RessourceGroup(
221 + titleRes: Int,
222 + items: List<RessourceToggleState>,
223 + onToggle: (kind: String, serverResourceId: String, enabled: Boolean) -> Unit,
224 +) {
225 + if (items.isEmpty()) return
226 + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
227 + Text(stringResource(titleRes), style = MaterialTheme.typography.labelMedium, color = TexteFaible)
228 + items.forEach { item ->
229 + ToggleRow(
230 + label = if (item.actif) item.nom else "${item.nom} — ${stringResource(R.string.settings_calendrier_ressource_inactive)}",
231 + checked = item.linked,
232 + enabled = item.actif,
233 + onCheckedChange = { enabled -> onToggle(item.kind, item.serverResourceId, enabled) },
234 + )
235 + }
236 + }
237 +}
238 +
239 +@Composable
240 +private fun ToggleRow(
241 + label: String,
242 + checked: Boolean,
243 + enabled: Boolean,
244 + onCheckedChange: (Boolean) -> Unit,
245 +) {
246 + Row(
247 + Modifier.fillMaxWidth(),
248 + verticalAlignment = Alignment.CenterVertically,
249 + horizontalArrangement = Arrangement.SpaceBetween,
250 + ) {
251 + Text(label, color = if (enabled) Ink else TexteFaible, modifier = Modifier.weight(1f))
252 + Switch(
253 + checked = checked,
254 + onCheckedChange = onCheckedChange,
255 + enabled = enabled,
256 + colors = SwitchDefaults.colors(
257 + checkedThumbColor = Ink,
258 + checkedTrackColor = Bordure,
259 + uncheckedThumbColor = TexteFaible,
260 + uncheckedTrackColor = Fond,
261 + uncheckedBorderColor = Bordure,
262 + ),
263 + )
99 264 }
100 265 }
101 266
M android/app/src/main/java/fr/ebii/card2vcf/ui/settings/SettingsViewModel.kt
+185 -4
@@ -2,11 +2,17 @@ package fr.ebii.card2vcf.ui.settings
2 2
3 3 import androidx.lifecycle.ViewModel
4 4 import androidx.lifecycle.viewModelScope
5 +import fr.ebii.card2vcf.sync.AgendaSyncCoordinator
5 6 import fr.ebii.card2vcf.sync.AilianceApi
6 7 import fr.ebii.card2vcf.sync.AilianceApiClient
7 8 import fr.ebii.card2vcf.sync.ApiCleCrypto
8 9 import fr.ebii.card2vcf.sync.ApiCleCryptoException
9 10 import fr.ebii.card2vcf.sync.AuthCleResponse
11 +import fr.ebii.card2vcf.sync.CalendarBinding
12 +import fr.ebii.card2vcf.sync.CalendarBindingsStore
13 +import fr.ebii.card2vcf.sync.CalendarBridgeApi
14 +import fr.ebii.card2vcf.sync.RessourceItemDto
15 +import fr.ebii.card2vcf.sync.RessourcesCatalogueDto
10 16 import fr.ebii.card2vcf.sync.SyncCredentialsStore
11 17 import kotlinx.coroutines.CoroutineDispatcher
12 18 import kotlinx.coroutines.Dispatchers
@@ -16,9 +22,35 @@ import kotlinx.coroutines.flow.asStateFlow
16 22 import kotlinx.coroutines.launch
17 23 import kotlinx.coroutines.withContext
18 24
25 +/** Toggle « ressource » (salle/matériel/véhicule) affiché en Paramètres, dérivé du catalogue serveur + liaisons locales. */
26 +data class RessourceToggleState(
27 + val kind: String,
28 + val serverResourceId: String,
29 + val nom: String,
30 + val displayName: String,
31 + val actif: Boolean,
32 + val linked: Boolean,
33 +)
34 +
35 +/** Liaison calendrier en attente de confirmation avant retrait (voir [SettingsViewModel.confirmRemoveBinding]). */
36 +data class PendingCalendarRemoval(
37 + val kind: String,
38 + val serverResourceId: String?,
39 + val displayName: String,
40 +)
41 +
19 42 /** État de l'écran Paramètres : connecté, ou formulaire de login (+ retape en attente). */
20 43 sealed interface SettingsUiState {
21 - data class LoggedIn(val userName: String, val baseUrl: String) : SettingsUiState
44 + data class LoggedIn(
45 + val userName: String,
46 + val baseUrl: String,
47 + val calendarPermissionGranted: Boolean = false,
48 + val mesRdvLinked: Boolean = false,
49 + val ressources: List<RessourceToggleState> = emptyList(),
50 + val catalogueLoading: Boolean = false,
51 + val catalogueError: String? = null,
52 + val pendingRemoval: PendingCalendarRemoval? = null,
53 + ) : SettingsUiState
22 54
23 55 data class LoggedOut(
24 56 val baseUrl: String = "",
@@ -32,10 +64,18 @@ sealed interface SettingsUiState {
32 64 ) : SettingsUiState
33 65 }
34 66
35 -/** Login clé API : [obtainKey] appelle `/api/auth/cle`, [confirmRetype] déchiffre et stocke. */
67 +/**
68 + * Login clé API : [obtainKey] appelle `/api/auth/cle`, [confirmRetype] déchiffre et stocke.
69 + * Une fois connecté, gère aussi la section Calendriers : permissions, liaison « Mes RDV » et
70 + * ressources actives (catalogue serveur), avec confirmation avant retrait d'une liaison.
71 + */
36 72 class SettingsViewModel(
37 73 private val credentialsStore: SyncCredentialsStore,
74 + private val calendarBindingsStore: CalendarBindingsStore,
75 + private val calendarBridge: CalendarBridgeApi,
38 76 private val apiFactory: (String) -> AilianceApi = { baseUrl -> AilianceApiClient(baseUrl) },
77 + private val authenticatedApiFactory: (String, String) -> AilianceApi =
78 + { baseUrl, apiKey -> AilianceApiClient(baseUrl, apiKey) },
39 79 private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
40 80 ) : ViewModel() {
41 81
@@ -46,7 +86,11 @@ class SettingsViewModel(
46 86 val baseUrl = credentialsStore.baseUrl
47 87 val userName = credentialsStore.userName
48 88 return if (credentialsStore.isConfigured() && baseUrl != null && userName != null) {
49 - SettingsUiState.LoggedIn(userName = userName, baseUrl = baseUrl)
89 + SettingsUiState.LoggedIn(
90 + userName = userName,
91 + baseUrl = baseUrl,
92 + mesRdvLinked = calendarBindingsStore.list().any { it.kind == AgendaSyncCoordinator.KIND_RDV },
93 + )
50 94 } else {
51 95 SettingsUiState.LoggedOut(baseUrl = baseUrl.orEmpty())
52 96 }
@@ -107,7 +151,11 @@ class SettingsViewModel(
107 151 try {
108 152 val apiKey = ApiCleCrypto.decryptApiKey(s.retypePassword, auth.sel, auth.cleChiffree)
109 153 credentialsStore.save(baseUrl = s.baseUrl.trim(), apiKey = apiKey, userName = auth.nom)
110 - _state.value = SettingsUiState.LoggedIn(userName = auth.nom, baseUrl = s.baseUrl.trim())
154 + _state.value = SettingsUiState.LoggedIn(
155 + userName = auth.nom,
156 + baseUrl = s.baseUrl.trim(),
157 + mesRdvLinked = calendarBindingsStore.list().any { it.kind == AgendaSyncCoordinator.KIND_RDV },
158 + )
111 159 } catch (e: ApiCleCryptoException) {
112 160 _state.value = s.copy(retypePassword = "", retypeError = "Mot de passe incorrect")
113 161 }
@@ -120,15 +168,148 @@ class SettingsViewModel(
120 168 _state.value = SettingsUiState.LoggedOut()
121 169 }
122 170
171 + // ---- Calendriers ----
172 +
173 + /** Appelé par l'écran après la demande de permission runtime (`READ_CALENDAR`/`WRITE_CALENDAR`). */
174 + fun onCalendarPermissionChanged(granted: Boolean) {
175 + updateLoggedIn { it.copy(calendarPermissionGranted = granted) }
176 + if (granted) refreshRessourcesCatalogue()
177 + }
178 +
179 + fun setMesRdvEnabled(enabled: Boolean) {
180 + val s = _state.value
181 + if (s !is SettingsUiState.LoggedIn) return
182 + if (enabled) {
183 + viewModelScope.launch {
184 + val calendarId = withContext(ioDispatcher) { calendarBridge.ensureLocalCalendar(MES_RDV_DISPLAY_NAME) }
185 + calendarBindingsStore.upsert(
186 + CalendarBinding(
187 + kind = AgendaSyncCoordinator.KIND_RDV,
188 + displayName = MES_RDV_DISPLAY_NAME,
189 + androidCalendarId = calendarId,
190 + ),
191 + )
192 + updateLoggedIn { it.copy(mesRdvLinked = true) }
193 + }
194 + } else {
195 + updateLoggedIn {
196 + it.copy(
197 + pendingRemoval = PendingCalendarRemoval(
198 + kind = AgendaSyncCoordinator.KIND_RDV,
199 + serverResourceId = null,
200 + displayName = MES_RDV_DISPLAY_NAME,
201 + ),
202 + )
203 + }
204 + }
205 + }
206 +
207 + fun setRessourceEnabled(kind: String, serverResourceId: String, enabled: Boolean) {
208 + val s = _state.value
209 + if (s !is SettingsUiState.LoggedIn) return
210 + val item = s.ressources.firstOrNull { it.kind == kind && it.serverResourceId == serverResourceId } ?: return
211 + if (!item.actif) return
212 + if (enabled) {
213 + viewModelScope.launch {
214 + val calendarId = withContext(ioDispatcher) { calendarBridge.ensureLocalCalendar(item.displayName) }
215 + calendarBindingsStore.upsert(
216 + CalendarBinding(
217 + kind = kind,
218 + serverResourceId = serverResourceId,
219 + displayName = item.displayName,
220 + androidCalendarId = calendarId,
221 + ),
222 + )
223 + updateLoggedIn { current -> current.copy(ressources = current.ressources.map { it.withLinked(kind, serverResourceId, true) }) }
224 + }
225 + } else {
226 + updateLoggedIn { it.copy(pendingRemoval = PendingCalendarRemoval(kind, serverResourceId, item.displayName)) }
227 + }
228 + }
229 +
230 + /**
231 + * Confirme le retrait d'une liaison (« Oui » du dialog de confirmation). Se limite à la liaison
232 + * ([CalendarBindingsStore.remove]) : [CalendarBridgeApi] n'expose pas de suppression de calendrier
233 + * Android, le calendrier local créé reste donc visible (vide) dans l'app Agenda système.
234 + */
235 + fun confirmRemoveBinding() {
236 + val s = _state.value
237 + if (s !is SettingsUiState.LoggedIn) return
238 + val pending = s.pendingRemoval ?: return
239 + calendarBindingsStore.remove(pending.kind, pending.serverResourceId)
240 + updateLoggedIn { current ->
241 + val unlinked = if (pending.kind == AgendaSyncCoordinator.KIND_RDV) {
242 + current.copy(mesRdvLinked = false)
243 + } else {
244 + current.copy(ressources = current.ressources.map { it.withLinked(pending.kind, pending.serverResourceId, false) })
245 + }
246 + unlinked.copy(pendingRemoval = null)
247 + }
248 + }
249 +
250 + fun dismissRemoveBinding() = updateLoggedIn { it.copy(pendingRemoval = null) }
251 +
252 + private fun RessourceToggleState.withLinked(kind: String, serverResourceId: String?, linked: Boolean): RessourceToggleState =
253 + if (this.kind == kind && this.serverResourceId == serverResourceId) copy(linked = linked) else this
254 +
255 + private fun refreshRessourcesCatalogue() {
256 + val s = _state.value
257 + if (s !is SettingsUiState.LoggedIn) return
258 + val baseUrl = credentialsStore.baseUrl
259 + val apiKey = credentialsStore.apiKey
260 + if (baseUrl == null || apiKey == null) return
261 + updateLoggedIn { it.copy(catalogueLoading = true, catalogueError = null) }
262 + viewModelScope.launch {
263 + val result = withContext(ioDispatcher) { authenticatedApiFactory(baseUrl, apiKey).getRessourcesCatalogue() }
264 + when (result) {
265 + is AilianceApiClient.ApiResult.Ok -> {
266 + val bindings = calendarBindingsStore.list()
267 + updateLoggedIn { it.copy(ressources = buildRessourceToggles(result.value, bindings), catalogueLoading = false) }
268 + }
269 + is AilianceApiClient.ApiResult.Err -> {
270 + updateLoggedIn { it.copy(catalogueLoading = false, catalogueError = result.message) }
271 + }
272 + }
273 + }
274 + }
275 +
276 + private fun buildRessourceToggles(catalogue: RessourcesCatalogueDto, bindings: List<CalendarBinding>): List<RessourceToggleState> {
277 + fun toggles(kind: String, items: List<RessourceItemDto>, label: String) = items.map { item ->
278 + RessourceToggleState(
279 + kind = kind,
280 + serverResourceId = item.id,
281 + nom = item.nom,
282 + displayName = "Card2vcf — $label ${item.nom}",
283 + actif = item.actif,
284 + linked = bindings.any { it.kind == kind && it.serverResourceId == item.id },
285 + )
286 + }
287 + return toggles(KIND_SALLE, catalogue.salles, "Salle") +
288 + toggles(KIND_MATERIEL, catalogue.materiels, "Matériel") +
289 + toggles(KIND_VEHICULE, catalogue.vehicules, "Véhicule")
290 + }
291 +
123 292 private inline fun updateLoggedOut(transform: (SettingsUiState.LoggedOut) -> SettingsUiState.LoggedOut) {
124 293 val s = _state.value
125 294 if (s is SettingsUiState.LoggedOut) _state.value = transform(s)
126 295 }
127 296
297 + private inline fun updateLoggedIn(transform: (SettingsUiState.LoggedIn) -> SettingsUiState.LoggedIn) {
298 + val s = _state.value
299 + if (s is SettingsUiState.LoggedIn) _state.value = transform(s)
300 + }
301 +
128 302 private fun errorMessageFor(code: Int, message: String): String = when (code) {
129 303 401 -> "Identifiants invalides"
130 304 404 -> "Aucune clé API pour cet utilisateur"
131 305 -1 -> "Erreur réseau : $message"
132 306 else -> message
133 307 }
308 +
309 + companion object {
310 + const val KIND_SALLE = "salle"
311 + const val KIND_MATERIEL = "materiel"
312 + const val KIND_VEHICULE = "vehicule"
313 + const val MES_RDV_DISPLAY_NAME = "Card2vcf — Mes RDV"
314 + }
134 315 }
M android/app/src/main/java/fr/ebii/card2vcf/ui/sync/SyncBanner.kt
+65 -18
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Row
6 6 import androidx.compose.foundation.layout.fillMaxWidth
7 7 import androidx.compose.foundation.layout.padding
8 8 import androidx.compose.foundation.shape.RoundedCornerShape
9 +import androidx.compose.material3.AlertDialog
9 10 import androidx.compose.material3.HorizontalDivider
10 11 import androidx.compose.material3.Text
11 12 import androidx.compose.material3.TextButton
@@ -15,42 +16,88 @@ import androidx.compose.ui.Modifier
15 16 import androidx.compose.ui.res.stringResource
16 17 import androidx.compose.ui.unit.dp
17 18 import fr.ebii.card2vcf.R
19 +import fr.ebii.card2vcf.sync.AgendaConflict
18 20 import fr.ebii.card2vcf.ui.theme.Bordure
21 +import fr.ebii.card2vcf.ui.theme.Fond
19 22 import fr.ebii.card2vcf.ui.theme.Ink
20 23
21 24 private val Square = RoundedCornerShape(0.dp)
22 25
23 -/** Bandeau hairline « N changement(s) sur le serveur » + Sync, ou message d'erreur ; masqué sinon. */
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.
29 + */
24 30 @Composable
25 31 fun SyncBanner(
26 32 pendingChanges: Int?,
33 + localAhead: Int,
27 34 syncing: Boolean,
28 35 error: String?,
29 36 onSyncClick: () -> Unit,
30 37 modifier: Modifier = Modifier,
31 38 ) {
32 - val label = when {
39 + val remoteLabel = when {
33 40 error != null -> error
34 - pendingChanges != null && pendingChanges > 0 ->
35 - stringResource(R.string.sync_pending, pendingChanges)
41 + pendingChanges != null && pendingChanges > 0 -> stringResource(R.string.sync_pending, pendingChanges)
36 42 else -> null
37 43 }
38 - if (label == null) return
44 + if (remoteLabel == null && localAhead <= 0) return
39 45
40 46 Column(modifier.fillMaxWidth()) {
41 - Row(
42 - Modifier.fillMaxWidth().padding(vertical = 8.dp),
43 - verticalAlignment = Alignment.CenterVertically,
44 - horizontalArrangement = Arrangement.SpaceBetween,
45 - ) {
46 - Text(label, color = Ink, modifier = Modifier.weight(1f))
47 - TextButton(onClick = onSyncClick, enabled = !syncing, shape = Square) {
48 - Text(
49 - if (syncing) stringResource(R.string.sync_en_cours) else stringResource(R.string.sync_bouton),
50 - color = Ink,
51 - )
52 - }
47 + if (remoteLabel != null) {
48 + SyncBannerRow(label = remoteLabel, syncing = syncing, onSyncClick = onSyncClick)
49 + }
50 + if (localAhead > 0) {
51 + SyncBannerRow(
52 + label = stringResource(R.string.sync_local_en_avance),
53 + syncing = syncing,
54 + onSyncClick = onSyncClick,
55 + )
56 + }
57 + }
58 +}
59 +
60 +@Composable
61 +private fun SyncBannerRow(label: String, syncing: Boolean, onSyncClick: () -> Unit) {
62 + Row(
63 + Modifier.fillMaxWidth().padding(vertical = 8.dp),
64 + verticalAlignment = Alignment.CenterVertically,
65 + horizontalArrangement = Arrangement.SpaceBetween,
66 + ) {
67 + Text(label, color = Ink, modifier = Modifier.weight(1f))
68 + TextButton(onClick = onSyncClick, enabled = !syncing, shape = Square) {
69 + Text(
70 + if (syncing) stringResource(R.string.sync_en_cours) else stringResource(R.string.sync_bouton),
71 + color = Ink,
72 + )
53 73 }
54 - HorizontalDivider(color = Bordure, thickness = 1.dp)
55 74 }
75 + HorizontalDivider(color = Bordure, thickness = 1.dp)
76 +}
77 +
78 +/** Dialog « Annuler ma réservation ? » affiché après un [SyncChromeViewModel.syncNow] en conflit (409). */
79 +@Composable
80 +fun SyncConflictDialog(
81 + conflict: AgendaConflict?,
82 + onConfirm: () -> Unit,
83 + onDismiss: () -> Unit,
84 +) {
85 + if (conflict == null) return
86 + AlertDialog(
87 + onDismissRequest = onDismiss,
88 + shape = Square,
89 + title = { Text(stringResource(R.string.sync_conflit_titre), color = Ink) },
90 + text = { Text(stringResource(R.string.sync_conflit_message), color = Ink) },
91 + confirmButton = {
92 + TextButton(onClick = onConfirm, shape = Square) {
93 + Text(stringResource(R.string.sync_conflit_oui), color = Ink)
94 + }
95 + },
96 + dismissButton = {
97 + TextButton(onClick = onDismiss, shape = Square) {
98 + Text(stringResource(R.string.sync_conflit_non), color = Ink)
99 + }
100 + },
101 + containerColor = Fond,
102 + )
56 103 }
M android/app/src/main/java/fr/ebii/card2vcf/ui/sync/SyncChromeViewModel.kt
+52 -4
@@ -2,6 +2,10 @@ package fr.ebii.card2vcf.ui.sync
2 2
3 3 import androidx.lifecycle.ViewModel
4 4 import androidx.lifecycle.viewModelScope
5 +import fr.ebii.card2vcf.sync.AgendaConflict
6 +import fr.ebii.card2vcf.sync.AgendaSyncCoordinator
7 +import fr.ebii.card2vcf.sync.CalendarBindingsStore
8 +import fr.ebii.card2vcf.sync.CalendarBridgeApi
5 9 import fr.ebii.card2vcf.sync.SyncCredentialsStore
6 10 import fr.ebii.card2vcf.sync.SyncEngine
7 11 import kotlinx.coroutines.flow.MutableStateFlow
@@ -9,9 +13,15 @@ import kotlinx.coroutines.flow.StateFlow
9 13 import kotlinx.coroutines.flow.asStateFlow
10 14 import kotlinx.coroutines.launch
11 15
12 -/** État bandeau sync (bouton toujours dispo) : status à l'ouverture, sync manuelle à la demande. */
16 +/**
17 + * État bandeau sync : changements serveur + calendrier local en avance à l'ouverture, sync manuelle
18 + * à 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.
20 + */
13 21 class SyncChromeViewModel(
14 22 private val credentialsStore: SyncCredentialsStore,
23 + private val calendarBindingsStore: CalendarBindingsStore,
24 + private val calendarBridge: CalendarBridgeApi?,
15 25 private val engineFactory: () -> SyncEngine?,
16 26 ) : ViewModel() {
17 27
@@ -21,19 +31,29 @@ class SyncChromeViewModel(
21 31 private val _pendingRemoteChanges = MutableStateFlow<Int?>(null)
22 32 val pendingRemoteChanges: StateFlow<Int?> = _pendingRemoteChanges.asStateFlow()
23 33
34 + private val _localCalendarAhead = MutableStateFlow(0)
35 + val localCalendarAhead: StateFlow<Int> = _localCalendarAhead.asStateFlow()
36 +
24 37 private val _syncing = MutableStateFlow(false)
25 38 val syncing: StateFlow<Boolean> = _syncing.asStateFlow()
26 39
27 40 private val _error = MutableStateFlow<String?>(null)
28 41 val error: StateFlow<String?> = _error.asStateFlow()
29 42
43 + private val pendingConflicts = ArrayDeque<AgendaConflict>()
44 + private val _conflictPending = MutableStateFlow<AgendaConflict?>(null)
45 + val conflictPending: StateFlow<AgendaConflict?> = _conflictPending.asStateFlow()
46 +
30 47 fun refreshStatus() {
31 48 _configured.value = credentialsStore.isConfigured()
32 49 val engine = engineFactory() ?: return
33 50 viewModelScope.launch {
34 - val result = engine.checkStatus()
51 + val bindings = calendarBindingsStore.list()
52 + val ressourcesQuery = AgendaSyncCoordinator.ressourcesQuery(bindings)
53 + val result = engine.checkStatus(ressourcesQuery)
35 54 _pendingRemoteChanges.value = result.pendingRemoteChanges
36 55 _error.value = result.error
56 + _localCalendarAhead.value = engine.localAheadCount(bindings, calendarBridge)
37 57 }
38 58 }
39 59
@@ -42,15 +62,43 @@ class SyncChromeViewModel(
42 62 viewModelScope.launch {
43 63 _syncing.value = true
44 64 _error.value = null
45 - val result = engine.syncNow()
65 + val bindings = calendarBindingsStore.list()
66 + val result = engine.syncNow(bindings, calendarBridge)
46 67 if (result.success) {
47 - val status = engine.checkStatus()
68 + setPendingConflicts(result.conflicts)
69 + val status = engine.checkStatus(AgendaSyncCoordinator.ressourcesQuery(bindings))
48 70 _pendingRemoteChanges.value = status.pendingRemoteChanges
49 71 _error.value = status.error
50 72 } else {
51 73 _error.value = result.error
52 74 }
75 + _localCalendarAhead.value = engine.localAheadCount(bindings, calendarBridge)
53 76 _syncing.value = false
54 77 }
55 78 }
79 +
80 + /** Dialog conflit, « Oui » : abandonne la réservation locale (entité + SyncOp + event Agenda), passe au conflit suivant. */
81 + fun confirmConflictCancellation() {
82 + val conflict = _conflictPending.value ?: return
83 + val engine = engineFactory() ?: return
84 + viewModelScope.launch {
85 + engine.abandonLocalReservation(conflict.localReservationId, calendarBridge)
86 + advanceConflictQueue()
87 + _localCalendarAhead.value = engine.localAheadCount(calendarBindingsStore.list(), calendarBridge)
88 + }
89 + }
90 +
91 + /** Dialog conflit, « Non » : ne touche pas à la réservation (reste `conflictPending`, arbitrage sur le serveur web). */
92 + fun dismissConflict() = advanceConflictQueue()
93 +
94 + private fun setPendingConflicts(conflicts: List<AgendaConflict>) {
95 + pendingConflicts.clear()
96 + pendingConflicts.addAll(conflicts)
97 + _conflictPending.value = pendingConflicts.firstOrNull()
98 + }
99 +
100 + private fun advanceConflictQueue() {
101 + if (pendingConflicts.isNotEmpty()) pendingConflicts.removeFirst()
102 + _conflictPending.value = pendingConflicts.firstOrNull()
103 + }
56 104 }
M android/app/src/main/res/values-en/strings.xml
+19 -0
@@ -95,10 +95,29 @@
95 95 <string name="settings_cle_configuree">API key: •••• (configured)</string>
96 96 <string name="settings_deconnecter">Sign out</string>
97 97
98 + <string name="settings_calendriers_titre">Calendars</string>
99 + <string name="settings_calendrier_permission_manquante">Allow calendar access to link your appointments and resources.</string>
100 + <string name="settings_calendrier_autoriser">Allow calendar</string>
101 + <string name="settings_calendrier_mes_rdv">My appointments</string>
102 + <string name="settings_calendrier_salles">Rooms</string>
103 + <string name="settings_calendrier_materiels">Equipment</string>
104 + <string name="settings_calendrier_vehicules">Vehicles</string>
105 + <string name="settings_calendrier_chargement">Loading resources…</string>
106 + <string name="settings_calendrier_ressource_inactive">inactive</string>
107 + <string name="settings_calendrier_retirer_titre">Remove this calendar?</string>
108 + <string name="settings_calendrier_retirer_message">"%1$s" will no longer sync. The calendar stays visible in the Calendar app.</string>
109 + <string name="settings_calendrier_retirer_confirmer">Remove</string>
110 + <string name="settings_calendrier_retirer_annuler">Cancel</string>
111 +
98 112 <string name="sync_pending">%d change(s) on the server</string>
99 113 <string name="sync_bouton">Sync</string>
100 114 <string name="sync_en_cours">Syncing…</string>
101 115 <string name="sync_icone">Sync</string>
116 + <string name="sync_local_en_avance">Local calendar is ahead of the server</string>
117 + <string name="sync_conflit_titre">Cancel my reservation?</string>
118 + <string name="sync_conflit_message">This reservation conflicts with another one on the server. Do you want to cancel it?</string>
119 + <string name="sync_conflit_oui">Yes, cancel</string>
120 + <string name="sync_conflit_non">No</string>
102 121
103 122 <string name="onglet_carnet">Address book</string>
104 123 <string name="onglet_projets">Projects</string>
M android/app/src/main/res/values/strings.xml
+19 -0
@@ -95,10 +95,29 @@
95 95 <string name="settings_cle_configuree">Clé API : •••• (configurée)</string>
96 96 <string name="settings_deconnecter">Déconnecter</string>
97 97
98 + <string name="settings_calendriers_titre">Calendriers</string>
99 + <string name="settings_calendrier_permission_manquante">Autorisez l\'accès à l\'agenda pour lier vos rendez-vous et vos ressources.</string>
100 + <string name="settings_calendrier_autoriser">Autoriser l\'agenda</string>
101 + <string name="settings_calendrier_mes_rdv">Mes RDV</string>
102 + <string name="settings_calendrier_salles">Salles</string>
103 + <string name="settings_calendrier_materiels">Matériel</string>
104 + <string name="settings_calendrier_vehicules">Véhicules</string>
105 + <string name="settings_calendrier_chargement">Chargement des ressources…</string>
106 + <string name="settings_calendrier_ressource_inactive">inactive</string>
107 + <string name="settings_calendrier_retirer_titre">Retirer ce calendrier ?</string>
108 + <string name="settings_calendrier_retirer_message">« %1$s » ne sera plus synchronisé. Le calendrier reste visible dans l\'app Agenda.</string>
109 + <string name="settings_calendrier_retirer_confirmer">Retirer</string>
110 + <string name="settings_calendrier_retirer_annuler">Annuler</string>
111 +
98 112 <string name="sync_pending">%d changement(s) sur le serveur</string>
99 113 <string name="sync_bouton">Synchroniser</string>
100 114 <string name="sync_en_cours">Synchronisation…</string>
101 115 <string name="sync_icone">Synchroniser</string>
116 + <string name="sync_local_en_avance">Calendrier local en avance sur le serveur</string>
117 + <string name="sync_conflit_titre">Annuler ma réservation ?</string>
118 + <string name="sync_conflit_message">Cette réservation est en conflit avec une autre sur le serveur. Voulez-vous l\'annuler ?</string>
119 + <string name="sync_conflit_oui">Oui, annuler</string>
120 + <string name="sync_conflit_non">Non</string>
102 121
103 122 <string name="onglet_carnet">Carnet</string>
104 123 <string name="onglet_projets">Projets</string>
A android/app/src/test/java/fr/ebii/card2vcf/data/AgendaSchemaDaoTest.kt
+123 -0
@@ -0,0 +1,123 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import android.content.Context
4 +import androidx.room.Room
5 +import androidx.test.core.app.ApplicationProvider
6 +import kotlinx.coroutines.runBlocking
7 +import org.junit.After
8 +import org.junit.Assert.assertEquals
9 +import org.junit.Assert.assertNotNull
10 +import org.junit.Assert.assertNull
11 +import org.junit.Assert.assertTrue
12 +import org.junit.Before
13 +import org.junit.Test
14 +import org.junit.runner.RunWith
15 +import org.robolectric.RobolectricTestRunner
16 +import org.robolectric.annotation.Config
17 +
18 +@RunWith(RobolectricTestRunner::class)
19 +@Config(sdk = [31])
20 +class AgendaSchemaDaoTest {
21 + private lateinit var db: CrmDatabase
22 + private lateinit var rdvDao: RdvDao
23 + private lateinit var reservationDao: ReservationDao
24 + private lateinit var indisponibiliteDao: IndisponibiliteDao
25 +
26 + @Before
27 + fun setUp() {
28 + val ctx = ApplicationProvider.getApplicationContext<Context>()
29 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java)
30 + .allowMainThreadQueries()
31 + .build()
32 + rdvDao = db.rdvDao()
33 + reservationDao = db.reservationDao()
34 + indisponibiliteDao = db.indisponibiliteDao()
35 + }
36 +
37 + @After
38 + fun tearDown() = db.close()
39 +
40 + @Test
41 + fun rdvUpsertGetListDirtyAndDelete() = runBlocking {
42 + val localId = rdvDao.upsert(
43 + RdvEntity(
44 + serverId = "rdv-1",
45 + titre = "Point client",
46 + lieu = "Bureau",
47 + debutMs = 1_000L,
48 + finMs = 2_000L,
49 + updatedAt = 1_000L,
50 + dirtyLocal = true,
51 + ),
52 + )
53 +
54 + val found = rdvDao.getByServerId("rdv-1")
55 + assertNotNull(found)
56 + assertEquals(localId, found!!.localId)
57 + assertEquals("Point client", found.titre)
58 + assertTrue(found.dirtyLocal)
59 +
60 + assertEquals(1, rdvDao.listDirty().size)
61 +
62 + rdvDao.upsert(found.copy(titre = "Point client reporté", updatedAt = 2_000L, dirtyLocal = false))
63 + assertEquals("Point client reporté", rdvDao.getByServerId("rdv-1")!!.titre)
64 + assertEquals(0, rdvDao.listDirty().size)
65 +
66 + rdvDao.deleteByServerId("rdv-1")
67 + assertNull(rdvDao.getByServerId("rdv-1"))
68 + }
69 +
70 + @Test
71 + fun reservationUpsertListByCibleAndConflictPending() = runBlocking {
72 + val localId = reservationDao.upsert(
73 + ReservationEntity(
74 + serverId = "resa-1",
75 + cibleType = "salle",
76 + cibleId = "salle-a",
77 + debutMs = 1_000L,
78 + finMs = 2_000L,
79 + motif = "Réunion",
80 + updatedAt = 1_000L,
81 + dirtyLocal = true,
82 + ),
83 + )
84 +
85 + val found = reservationDao.getByServerId("resa-1")
86 + assertNotNull(found)
87 + assertEquals(localId, found!!.localId)
88 + assertEquals("active", found.statut)
89 + assertEquals(1, reservationDao.listByCible("salle", "salle-a").size)
90 + assertEquals(1, reservationDao.listDirty().size)
91 +
92 + reservationDao.upsert(found.copy(conflictPending = true, dirtyLocal = false))
93 + val updated = reservationDao.getByServerId("resa-1")
94 + assertTrue(updated!!.conflictPending)
95 + assertEquals(0, reservationDao.listDirty().size)
96 +
97 + reservationDao.deleteByServerId("resa-1")
98 + assertNull(reservationDao.getByServerId("resa-1"))
99 + }
100 +
101 + @Test
102 + fun indisponibiliteUpsertListByCibleAndDelete() = runBlocking {
103 + indisponibiliteDao.upsert(
104 + IndisponibiliteEntity(
105 + serverId = "indispo-1",
106 + cibleType = "vehicule",
107 + cibleId = "vehicule-a",
108 + debutMs = 1_000L,
109 + finMs = 2_000L,
110 + nature = "maintenance",
111 + updatedAt = 1_000L,
112 + ),
113 + )
114 +
115 + val found = indisponibiliteDao.getByServerId("indispo-1")
116 + assertNotNull(found)
117 + assertEquals("maintenance", found!!.nature)
118 + assertEquals(1, indisponibiliteDao.listByCible("vehicule", "vehicule-a").size)
119 +
120 + indisponibiliteDao.deleteByServerId("indispo-1")
121 + assertNull(indisponibiliteDao.getByServerId("indispo-1"))
122 + }
123 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/AgendaSyncEngineTest.kt
+478 -0
@@ -0,0 +1,478 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import android.content.Context
4 +import androidx.room.Room
5 +import androidx.test.core.app.ApplicationProvider
6 +import fr.ebii.card2vcf.data.CrmDatabase
7 +import fr.ebii.card2vcf.data.RdvEntity
8 +import fr.ebii.card2vcf.data.ReservationEntity
9 +import kotlinx.coroutines.runBlocking
10 +import org.junit.After
11 +import org.junit.Assert.assertEquals
12 +import org.junit.Assert.assertNull
13 +import org.junit.Assert.assertTrue
14 +import org.junit.Before
15 +import org.junit.Test
16 +import org.junit.runner.RunWith
17 +import org.robolectric.RobolectricTestRunner
18 +import org.robolectric.annotation.Config
19 +import java.time.Instant
20 +
21 +/** Task M5 : LWW RDV, conflit 409 réservation, abandon, `ressourcesQuery`, pont Agenda↔Room. */
22 +@RunWith(RobolectricTestRunner::class)
23 +@Config(sdk = [31])
24 +class AgendaSyncEngineTest {
25 + private lateinit var db: CrmDatabase
26 + private lateinit var api: FakeAilianceApi
27 + private lateinit var engine: SyncEngine
28 +
29 + @Before
30 + fun setUp() {
31 + val ctx = ApplicationProvider.getApplicationContext<Context>()
32 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java)
33 + .allowMainThreadQueries()
34 + .build()
35 + api = FakeAilianceApi()
36 + engine = SyncEngine(api, db)
37 + }
38 +
39 + @After
40 + fun tearDown() = db.close()
41 +
42 + private fun epoch(iso: String) = Instant.parse(iso).toEpochMilli()
43 +
44 + // ---- LWW RDV ----
45 +
46 + @Test
47 + fun syncNow_rdvPullNewerServerOverwritesCleanLocal() = runBlocking {
48 + db.rdvDao().upsert(
49 + RdvEntity(serverId = "rdv1", titre = "Ancien titre", updatedAt = epoch("2026-07-20T10:00:00Z")),
50 + )
51 + api.pullResult = AilianceApiClient.ApiResult.Ok(
52 + SyncPullResponse(
53 + serverTime = "2026-07-22T12:00:00Z",
54 + rdv = listOf(
55 + RendezVousDto(
56 + id = "rdv1",
57 + titre = "Nouveau titre serveur",
58 + debut = "2026-07-22T09:00:00Z",
59 + fin = "2026-07-22T10:00:00Z",
60 + creeLe = "2026-07-20T10:00:00Z",
61 + misAJourLe = "2026-07-22T10:00:00Z",
62 + ),
63 + ),
64 + ),
65 + )
66 +
67 + engine.syncNow()
68 +
69 + val updated = db.rdvDao().getByServerId("rdv1")
70 + assertEquals("Nouveau titre serveur", updated?.titre)
71 + }
72 +
73 + @Test
74 + fun syncNow_rdvPullDirtyLocalNewerKeepsLocal() = runBlocking {
75 + db.rdvDao().upsert(
76 + RdvEntity(
77 + serverId = "rdv2",
78 + titre = "Titre local en avance",
79 + updatedAt = epoch("2026-07-22T10:00:00Z"),
80 + dirtyLocal = true,
81 + ),
82 + )
83 + api.pullResult = AilianceApiClient.ApiResult.Ok(
84 + SyncPullResponse(
85 + serverTime = "2026-07-22T12:00:00Z",
86 + rdv = listOf(
87 + RendezVousDto(
88 + id = "rdv2",
89 + titre = "Titre serveur périmé",
90 + debut = "2026-07-19T09:00:00Z",
91 + fin = "2026-07-19T10:00:00Z",
92 + creeLe = "2026-07-18T10:00:00Z",
93 + misAJourLe = "2026-07-20T10:00:00Z",
94 + ),
95 + ),
96 + ),
97 + )
98 +
99 + engine.syncNow()
100 +
101 + val kept = db.rdvDao().getByServerId("rdv2")
102 + assertEquals("Titre local en avance", kept?.titre)
103 + assertTrue(kept?.dirtyLocal == true)
104 + }
105 +
106 + // ---- Conflit 409 réservation ----
107 +
108 + @Test
109 + fun syncNow_createReservation409SetsConflictPendingAndKeepsSyncOp() = runBlocking {
110 + val localId = db.reservationDao().upsert(
111 + ReservationEntity(cibleType = "salle", cibleId = "salle-1", motif = "Réunion", dirtyLocal = true),
112 + )
113 + db.syncOpDao().insert(
114 + SyncOpEntity(
115 + entityType = "reservation",
116 + op = "create",
117 + payloadJson = """{"debut":"2026-07-22T09:00:00Z","fin":"2026-07-22T10:00:00Z"}""",
118 + localId = localId,
119 + createdAt = 1L,
120 + ),
121 + )
122 + api.createReservationResult = AilianceApiClient.ApiResult.Err(409, "Chevauchement avec une autre réservation")
123 +
124 + val result = engine.syncNow()
125 +
126 + assertTrue(result.conflicts.isNotEmpty())
127 + assertEquals(localId, result.conflicts.single().localReservationId)
128 + assertTrue(db.reservationDao().getByLocalId(localId)?.conflictPending == true)
129 + assertEquals(1, db.syncOpDao().listAll().size)
130 + assertEquals("salle", api.createReservationCalls.single().first)
131 + assertEquals("salle-1", api.createReservationCalls.single().second)
132 + }
133 +
134 + // ---- Abandon ----
135 +
136 + @Test
137 + fun abandonLocalReservation_removesEntitySyncOpsAndCalendarEvent() = runBlocking {
138 + val localId = db.reservationDao().upsert(
139 + ReservationEntity(
140 + cibleType = "salle",
141 + cibleId = "salle-1",
142 + motif = "Réunion",
143 + conflictPending = true,
144 + calendarEventId = 42L,
145 + ),
146 + )
147 + db.syncOpDao().insert(
148 + SyncOpEntity(entityType = "reservation", op = "create", localId = localId, createdAt = 1L),
149 + )
150 + val bridge = FakeCalendarBridge()
151 +
152 + engine.abandonLocalReservation(localId, bridge)
153 +
154 + assertNull(db.reservationDao().getByLocalId(localId))
155 + assertTrue(db.syncOpDao().listAll().isEmpty())
156 + assertTrue(bridge.deletedEventIds.contains(42L))
157 + }
158 +
159 + // ---- ressourcesQuery ----
160 +
161 + @Test
162 + fun ressourcesQuery_buildsFromNonRdvBindingsWithServerResourceId() {
163 + val coordinator = AgendaSyncCoordinator(db)
164 + val bindings = listOf(
165 + CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = 1L),
166 + CalendarBinding(kind = "salle", serverResourceId = "s1", displayName = "Salle A", androidCalendarId = 2L),
167 + CalendarBinding(kind = "materiel", serverResourceId = "m1", displayName = "Matériel B", androidCalendarId = 3L),
168 + CalendarBinding(kind = "vehicule", serverResourceId = null, displayName = "Sans id serveur", androidCalendarId = 4L),
169 + )
170 +
171 + assertEquals("salle:s1,materiel:m1", coordinator.ressourcesQuery(bindings))
172 + }
173 +
174 + @Test
175 + fun ressourcesQuery_emptyBindingsReturnsNull() {
176 + val coordinator = AgendaSyncCoordinator(db)
177 +
178 + assertNull(coordinator.ressourcesQuery(emptyList()))
179 + }
180 +
181 + @Test
182 + fun syncNow_pullUsesRessourcesQueryDerivedFromBindings() = runBlocking {
183 + val bridge = FakeCalendarBridge()
184 + val rdvCalendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
185 + val salleCalendarId = bridge.ensureLocalCalendar("Card2vcf — Salle A")
186 + val bindings = listOf(
187 + CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = rdvCalendarId),
188 + CalendarBinding(kind = "salle", serverResourceId = "s1", displayName = "Salle A", androidCalendarId = salleCalendarId),
189 + )
190 +
191 + engine.syncNow(bindings, bridge)
192 +
193 + assertEquals("salle:s1", api.pullQueries.last())
194 + }
195 +
196 + @Test
197 + fun checkStatus_forwardsRessourcesQueryToApi() = runBlocking {
198 + engine.checkStatus("salle:s1")
199 +
200 + assertEquals("salle:s1", api.statusQueries.last())
201 + }
202 +
203 + @Test
204 + fun syncNow_withoutBindingsOrBridgeOmitsRessourcesQuery() = runBlocking {
205 + engine.syncNow()
206 +
207 + assertNull(api.pullQueries.last())
208 + }
209 +
210 + // ---- Agenda -> Room / Room -> Agenda ----
211 +
212 + @Test
213 + fun syncNow_agendaToRoomCreatesRdvFromNewCalendarEventAndPushesIt() = runBlocking {
214 + val bridge = FakeCalendarBridge()
215 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
216 + bridge.seedEvent(
217 + calendarId,
218 + CalendarEventSnapshot(title = "RDV client", debutMs = 1_000L, finMs = 2_000L, descriptionBody = "notes"),
219 + )
220 + val bindings = listOf(CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = calendarId))
221 +
222 + engine.syncNow(bindings, bridge)
223 +
224 + val stored = db.rdvDao().listAll()
225 + assertEquals(1, stored.size)
226 + assertEquals("RDV client", stored.single().titre)
227 + assertEquals(1, api.createRdvCalls.size)
228 + assertTrue(db.syncOpDao().listAll().none { it.entityType == "rdv" })
229 + }
230 +
231 + @Test
232 + fun syncNow_roomToAgendaUpsertsRdvEventAndStoresCalendarEventId() = runBlocking {
233 + val bridge = FakeCalendarBridge()
234 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
235 + val localId = db.rdvDao().upsert(
236 + RdvEntity(serverId = "rdv-9", titre = "RDV serveur", debutMs = 5_000L, finMs = 6_000L, updatedAt = 1L),
237 + )
238 + val bindings = listOf(CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = calendarId))
239 +
240 + engine.syncNow(bindings, bridge)
241 +
242 + val updated = db.rdvDao().getByLocalId(localId)
243 + assertTrue(updated?.calendarEventId != null)
244 + assertEquals(1, bridge.listEvents(calendarId).size)
245 + assertEquals("RDV serveur", bridge.listEvents(calendarId).single().title)
246 + }
247 +
248 + // ---- Fix HIGH : roomToAgenda supprime les events orphelins ----
249 +
250 + @Test
251 + fun syncNow_roomToAgendaDeletesOrphanEventAfterRdvTombstone() = runBlocking {
252 + val bridge = FakeCalendarBridge()
253 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
254 + val eventId = bridge.seedEvent(
255 + calendarId,
256 + CalendarEventSnapshot(title = "RDV serveur", debutMs = 1_000L, finMs = 2_000L, serverId = "rdv-old"),
257 + )
258 + db.rdvDao().upsert(RdvEntity(serverId = "rdv-old", titre = "RDV serveur", calendarEventId = eventId, updatedAt = 1L))
259 + val bindings = listOf(CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = calendarId))
260 + api.pullResult = AilianceApiClient.ApiResult.Ok(
261 + SyncPullResponse(
262 + serverTime = "2026-07-22T12:00:00Z",
263 + tombstones = listOf(TombstoneDto(entityType = "rdv", id = "rdv-old", supprimeLe = "2026-07-22T12:00:00Z")),
264 + ),
265 + )
266 +
267 + engine.syncNow(bindings, bridge)
268 +
269 + assertNull(db.rdvDao().getByServerId("rdv-old"))
270 + assertTrue(bridge.deletedEventIds.contains(eventId))
271 + assertTrue(bridge.listEvents(calendarId).isEmpty())
272 + }
273 +
274 + @Test
275 + fun syncNow_roomToAgendaDeletesOrphanEventAfterReservationBecomesInactive() = runBlocking {
276 + val bridge = FakeCalendarBridge()
277 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Salle A")
278 + val eventId = bridge.seedEvent(
279 + calendarId,
280 + CalendarEventSnapshot(title = "Réunion", debutMs = 1_000L, finMs = 2_000L, serverId = "resa-1"),
281 + )
282 + db.reservationDao().upsert(
283 + ReservationEntity(
284 + serverId = "resa-1",
285 + cibleType = "salle",
286 + cibleId = "s1",
287 + motif = "Réunion",
288 + calendarEventId = eventId,
289 + updatedAt = 1L,
290 + statut = "active",
291 + ),
292 + )
293 + val bindings = listOf(CalendarBinding(kind = "salle", serverResourceId = "s1", displayName = "Salle A", androidCalendarId = calendarId))
294 + api.pullResult = AilianceApiClient.ApiResult.Ok(
295 + SyncPullResponse(
296 + serverTime = "2026-07-22T12:00:00Z",
297 + reservations = listOf(
298 + ReservationDto(
299 + id = "resa-1",
300 + cible = CibleRessourceDto(type = "salle", id = "s1"),
301 + debut = "2026-07-22T09:00:00Z",
302 + fin = "2026-07-22T10:00:00Z",
303 + statut = "annulee",
304 + creeLe = "2026-07-20T10:00:00Z",
305 + misAJourLe = "2026-07-22T11:00:00Z",
306 + ),
307 + ),
308 + ),
309 + )
310 +
311 + engine.syncNow(bindings, bridge)
312 +
313 + assertNull(db.reservationDao().getByServerId("resa-1"))
314 + assertTrue(bridge.deletedEventIds.contains(eventId))
315 + }
316 +
317 + @Test
318 + fun syncNow_roomToAgendaKeepsPendingPushEventEvenWithoutServerId() = runBlocking {
319 + val bridge = FakeCalendarBridge()
320 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
321 + val eventId = bridge.seedEvent(
322 + calendarId,
323 + CalendarEventSnapshot(title = "Nouveau RDV", debutMs = 1_000L, finMs = 2_000L),
324 + )
325 + val bindings = listOf(CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = calendarId))
326 + api.createRdvResult = AilianceApiClient.ApiResult.Err(-1, "Réseau indisponible")
327 +
328 + engine.syncNow(bindings, bridge)
329 +
330 + val stored = db.rdvDao().listAll().single()
331 + assertNull(stored.serverId)
332 + assertTrue(bridge.deletedEventIds.isEmpty())
333 + assertTrue(bridge.listEvents(calendarId).any { it.eventId == eventId })
334 + }
335 +
336 + // ---- Fix MEDIUM : agendaToRoom détecte les suppressions côté Agenda ----
337 +
338 + @Test
339 + fun agendaToRoom_detectsDeletedRdvEventWithServerIdEnqueuesDeleteOp() = runBlocking {
340 + val bridge = FakeCalendarBridge()
341 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
342 + db.rdvDao().upsert(RdvEntity(serverId = "rdv-5", titre = "RDV supprimé", calendarEventId = 99L, updatedAt = 1L))
343 + val bindings = listOf(CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = calendarId))
344 + val coordinator = AgendaSyncCoordinator(db)
345 +
346 + coordinator.agendaToRoom(bindings, bridge)
347 +
348 + val op = db.syncOpDao().listAll().single { it.entityType == "rdv" }
349 + assertEquals("delete", op.op)
350 + assertEquals("rdv-5", op.serverId)
351 + assertNull(db.rdvDao().getByServerId("rdv-5"))
352 + }
353 +
354 + @Test
355 + fun agendaToRoom_detectsDeletedRdvEventWithoutServerIdRemovesLocalEntityDirectly() = runBlocking {
356 + val bridge = FakeCalendarBridge()
357 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
358 + val localId = db.rdvDao().upsert(RdvEntity(titre = "RDV jamais poussé", calendarEventId = 77L, updatedAt = 1L))
359 + val bindings = listOf(CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = calendarId))
360 + val coordinator = AgendaSyncCoordinator(db)
361 +
362 + coordinator.agendaToRoom(bindings, bridge)
363 +
364 + assertNull(db.rdvDao().getByLocalId(localId))
365 + assertTrue(db.syncOpDao().listAll().none { it.entityType == "rdv" })
366 + }
367 +
368 + @Test
369 + fun agendaToRoom_detectsDeletedReservationEventWithServerIdEnqueuesDeleteOp() = runBlocking {
370 + val bridge = FakeCalendarBridge()
371 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Salle A")
372 + db.reservationDao().upsert(
373 + ReservationEntity(
374 + serverId = "resa-9",
375 + cibleType = "salle",
376 + cibleId = "s1",
377 + motif = "Réunion supprimée",
378 + calendarEventId = 55L,
379 + updatedAt = 1L,
380 + ),
381 + )
382 + val bindings = listOf(CalendarBinding(kind = "salle", serverResourceId = "s1", displayName = "Salle A", androidCalendarId = calendarId))
383 + val coordinator = AgendaSyncCoordinator(db)
384 +
385 + coordinator.agendaToRoom(bindings, bridge)
386 +
387 + val op = db.syncOpDao().listAll().single { it.entityType == "reservation" }
388 + assertEquals("delete", op.op)
389 + assertEquals("resa-9", op.serverId)
390 + assertNull(db.reservationDao().getByServerId("resa-9"))
391 + }
392 +
393 + @Test
394 + fun syncNow_pushesDeleteOpFromDeletedReservationEventUsingPayloadCibleFallback() = runBlocking {
395 + val bridge = FakeCalendarBridge()
396 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Salle A")
397 + db.reservationDao().upsert(
398 + ReservationEntity(
399 + serverId = "resa-9",
400 + cibleType = "salle",
401 + cibleId = "s1",
402 + motif = "Réunion supprimée",
403 + calendarEventId = 55L,
404 + updatedAt = 1L,
405 + ),
406 + )
407 + val bindings = listOf(CalendarBinding(kind = "salle", serverResourceId = "s1", displayName = "Salle A", androidCalendarId = calendarId))
408 +
409 + // agendaToRoom (via syncNow) supprime l'entité locale puis pushOps doit résoudre la cible via le payload de l'op.
410 + engine.syncNow(bindings, bridge)
411 +
412 + assertEquals(1, api.deleteReservationCalls.size)
413 + assertEquals(Triple("salle", "s1", "resa-9"), api.deleteReservationCalls.single())
414 + }
415 +
416 + @Test
417 + fun agendaToRoom_conflictPendingReservationNotDeletedWhenEventMissing() = runBlocking {
418 + val bridge = FakeCalendarBridge()
419 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Salle A")
420 + db.reservationDao().upsert(
421 + ReservationEntity(
422 + serverId = "resa-conflict",
423 + cibleType = "salle",
424 + cibleId = "s1",
425 + motif = "En conflit",
426 + calendarEventId = 12L,
427 + updatedAt = 1L,
428 + conflictPending = true,
429 + ),
430 + )
431 + val bindings = listOf(CalendarBinding(kind = "salle", serverResourceId = "s1", displayName = "Salle A", androidCalendarId = calendarId))
432 + val coordinator = AgendaSyncCoordinator(db)
433 +
434 + coordinator.agendaToRoom(bindings, bridge)
435 +
436 + assertTrue(db.reservationDao().getByServerId("resa-conflict") != null)
437 + assertTrue(db.syncOpDao().listAll().none { it.entityType == "reservation" })
438 + }
439 +
440 + // ---- Fix MEDIUM : localAheadCount avec events orphelins ----
441 +
442 + @Test
443 + fun localAheadCount_withoutBridgeCountsDirtyAndPendingOpsOnly() = runBlocking {
444 + db.rdvDao().upsert(RdvEntity(titre = "Dirty", dirtyLocal = true))
445 + db.syncOpDao().insert(SyncOpEntity(entityType = "reservation", op = "create", createdAt = 1L))
446 +
447 + val count = engine.localAheadCount()
448 +
449 + assertEquals(2, count)
450 + }
451 +
452 + @Test
453 + fun localAheadCount_withBridgeIncludesOrphanTaggedEvents() = runBlocking {
454 + val bridge = FakeCalendarBridge()
455 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
456 + bridge.seedEvent(
457 + calendarId,
458 + CalendarEventSnapshot(title = "Orphelin", debutMs = 1_000L, finMs = 2_000L, serverId = "rdv-orphan"),
459 + )
460 + val bindings = listOf(CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = calendarId))
461 +
462 + val count = engine.localAheadCount(bindings, bridge)
463 +
464 + assertEquals(1, count)
465 + }
466 +
467 + // ---- Fix LOW : delete réservation sans serverId ----
468 +
469 + @Test
470 + fun pushReservationDeleteWithoutServerIdDoesNotDeleteSyncOp() = runBlocking {
471 + val localId = db.reservationDao().upsert(ReservationEntity(cibleType = "salle", cibleId = "s1", motif = "x"))
472 + db.syncOpDao().insert(SyncOpEntity(entityType = "reservation", op = "delete", localId = localId, createdAt = 1L))
473 +
474 + engine.syncNow()
475 +
476 + assertEquals(1, db.syncOpDao().listAll().size)
477 + }
478 +}
M android/app/src/test/java/fr/ebii/card2vcf/sync/AilianceApiClientTest.kt
+124 -0
@@ -176,6 +176,130 @@ class AilianceApiClientTest {
176 176 }
177 177
178 178 @Test
179 + fun syncPull200ParsesAgendaFields() {
180 + server.enqueue(
181 + MockResponse()
182 + .setResponseCode(200)
183 + .setBody(
184 + """
185 + {
186 + "server_time": "2026-07-22T12:05:00Z",
187 + "contacts": [],
188 + "entreprises": [],
189 + "projets": [],
190 + "taches": [],
191 + "interactions": [],
192 + "rdv": [
193 + {
194 + "id": "rdv1",
195 + "titre": "Point client",
196 + "utilisateur": "alice",
197 + "debut": "2026-07-22T09:00:00Z",
198 + "fin": "2026-07-22T10:00:00Z",
199 + "cree_par": "alice",
200 + "cree_le": "2026-07-20T08:00:00Z"
201 + }
202 + ],
203 + "reservations": [
204 + {
205 + "id": "resa1",
206 + "cible": {"type": "salle", "id": "salle-a"},
207 + "debut": "2026-07-22T09:00:00Z",
208 + "fin": "2026-07-22T10:00:00Z",
209 + "motif": "Réunion",
210 + "statut": "active",
211 + "reserve_par": "alice",
212 + "cree_le": "2026-07-20T08:00:00Z"
213 + }
214 + ],
215 + "indisponibilites": [
216 + {
217 + "id": "indispo1",
218 + "cible": {"type": "vehicule", "id": "vehicule-a"},
219 + "nature": "entretien",
220 + "debut": "2026-07-22T09:00:00Z",
221 + "fin": "2026-07-22T10:00:00Z",
222 + "cree_par": "alice",
223 + "cree_le": "2026-07-20T08:00:00Z"
224 + }
225 + ],
226 + "tombstones": [],
227 + "workflows": []
228 + }
229 + """.trimIndent(),
230 + ),
231 + )
232 +
233 + val result = client.syncPull("2026-07-20T00:00:00Z")
234 +
235 + assertTrue(result is AilianceApiClient.ApiResult.Ok)
236 + val ok = result as AilianceApiClient.ApiResult.Ok
237 + assertEquals(1, ok.value.rdv.size)
238 + assertEquals("Point client", ok.value.rdv[0].titre)
239 + assertEquals(1, ok.value.reservations.size)
240 + assertEquals("salle", ok.value.reservations[0].cible.type)
241 + assertEquals("salle-a", ok.value.reservations[0].cible.id)
242 + assertEquals(1, ok.value.indisponibilites.size)
243 + assertEquals("entretien", ok.value.indisponibilites[0].nature)
244 + }
245 +
246 + @Test
247 + fun createReservation409ReturnsErrWithCode() {
248 + server.enqueue(
249 + MockResponse()
250 + .setResponseCode(409)
251 + .setBody("""{"message":"Réservation refusée : période en conflit.","reclamable":true}"""),
252 + )
253 +
254 + val result = client.createReservation(
255 + "salle",
256 + "salle-a",
257 + """{"debut":"2026-07-22T09:00:00Z","fin":"2026-07-22T10:00:00Z","motif":"Réunion"}""",
258 + )
259 +
260 + assertTrue(result is AilianceApiClient.ApiResult.Err)
261 + val err = result as AilianceApiClient.ApiResult.Err
262 + assertEquals(409, err.code)
263 + assertEquals("Réservation refusée : période en conflit.", err.message)
264 +
265 + val request = server.takeRequest()
266 + assertEquals("POST", request.method)
267 + assertEquals("/api/ressources/salle/salle-a/reservations", request.path)
268 + }
269 +
270 + @Test
271 + fun getRessourcesCatalogue200ParsesCatalogue() {
272 + server.enqueue(
273 + MockResponse()
274 + .setResponseCode(200)
275 + .setBody(
276 + """
277 + {
278 + "salles": [{"id": "salle-a", "nom": "Salle A", "actif": true}],
279 + "materiels": [],
280 + "vehicules": [{"id": "vehicule-a", "nom": "Véhicule A", "actif": false}]
281 + }
282 + """.trimIndent(),
283 + ),
284 + )
285 +
286 + val result = client.getRessourcesCatalogue()
287 +
288 + assertTrue(result is AilianceApiClient.ApiResult.Ok)
289 + val ok = result as AilianceApiClient.ApiResult.Ok
290 + assertEquals(1, ok.value.salles.size)
291 + assertEquals("Salle A", ok.value.salles[0].nom)
292 + assertTrue(ok.value.salles[0].actif)
293 + assertEquals(0, ok.value.materiels.size)
294 + assertEquals(1, ok.value.vehicules.size)
295 + assertTrue(!ok.value.vehicules[0].actif)
296 +
297 + val request = server.takeRequest()
298 + assertEquals("GET", request.method)
299 + assertEquals("/api/ressources", request.path)
300 + }
301 +
302 + @Test
179 303 fun protectedEndpointWithoutBearerReturns401() {
180 304 val unauthenticated = AilianceApiClient(
181 305 baseUrl = server.url("/").toString().trimEnd('/'),
A android/app/src/test/java/fr/ebii/card2vcf/sync/CalendarBindingsStoreTest.kt
+111 -0
@@ -0,0 +1,111 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import android.content.Context
4 +import androidx.test.core.app.ApplicationProvider
5 +import org.junit.Assert.assertEquals
6 +import org.junit.Assert.assertTrue
7 +import org.junit.Before
8 +import org.junit.Test
9 +import org.junit.runner.RunWith
10 +import org.robolectric.RobolectricTestRunner
11 +import org.robolectric.annotation.Config
12 +
13 +@RunWith(RobolectricTestRunner::class)
14 +@Config(sdk = [31])
15 +class CalendarBindingsStoreTest {
16 + private lateinit var store: CalendarBindingsStore
17 +
18 + @Before
19 + fun setUp() {
20 + val ctx = ApplicationProvider.getApplicationContext<Context>()
21 + val prefs = ctx.getSharedPreferences("${CalendarBindingsStore.PREFS_NAME}_test", Context.MODE_PRIVATE)
22 + prefs.edit().clear().apply()
23 + store = CalendarBindingsStore(prefs)
24 + }
25 +
26 + @Test
27 + fun initiallyEmpty() {
28 + assertTrue(store.list().isEmpty())
29 + }
30 +
31 + @Test
32 + fun upsertRoundTrip() {
33 + val binding = CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = 42L)
34 +
35 + store.upsert(binding)
36 +
37 + assertEquals(listOf(binding), store.list())
38 + }
39 +
40 + @Test
41 + fun upsertReplacesExistingBindingWithSameKindAndServerResourceId() {
42 + val original = CalendarBinding(
43 + kind = "salle",
44 + serverResourceId = "salle-1",
45 + displayName = "Salle A",
46 + androidCalendarId = 1L,
47 + )
48 + val renamed = original.copy(displayName = "Salle A (renommée)", androidCalendarId = 2L)
49 +
50 + store.upsert(original)
51 + store.upsert(renamed)
52 +
53 + assertEquals(listOf(renamed), store.list())
54 + }
55 +
56 + @Test
57 + fun upsertKeepsDistinctBindingsByKindAndServerResourceId() {
58 + val rdv = CalendarBinding(kind = "rdv", displayName = "Mes RDV")
59 + val salle = CalendarBinding(kind = "salle", serverResourceId = "salle-1", displayName = "Salle A")
60 + val materiel = CalendarBinding(kind = "materiel", serverResourceId = "materiel-1", displayName = "Matériel A")
61 +
62 + store.upsert(rdv)
63 + store.upsert(salle)
64 + store.upsert(materiel)
65 +
66 + assertEquals(setOf(rdv, salle, materiel), store.list().toSet())
67 + assertEquals(3, store.list().size)
68 + }
69 +
70 + @Test
71 + fun removeDeletesMatchingBinding() {
72 + val salle = CalendarBinding(kind = "salle", serverResourceId = "salle-1", displayName = "Salle A")
73 + store.upsert(salle)
74 +
75 + store.remove(kind = "salle", serverResourceId = "salle-1")
76 +
77 + assertTrue(store.list().isEmpty())
78 + }
79 +
80 + @Test
81 + fun removeOnlyAffectsMatchingKindAndServerResourceId() {
82 + val salleA = CalendarBinding(kind = "salle", serverResourceId = "salle-1", displayName = "Salle A")
83 + val salleB = CalendarBinding(kind = "salle", serverResourceId = "salle-2", displayName = "Salle B")
84 + store.upsert(salleA)
85 + store.upsert(salleB)
86 +
87 + store.remove(kind = "salle", serverResourceId = "salle-1")
88 +
89 + assertEquals(listOf(salleB), store.list())
90 + }
91 +
92 + @Test
93 + fun removeWithNullServerResourceIdTargetsRdvBinding() {
94 + val rdv = CalendarBinding(kind = "rdv", displayName = "Mes RDV")
95 + store.upsert(rdv)
96 +
97 + store.remove(kind = "rdv", serverResourceId = null)
98 +
99 + assertTrue(store.list().isEmpty())
100 + }
101 +
102 + @Test
103 + fun clearRemovesAllBindings() {
104 + store.upsert(CalendarBinding(kind = "rdv", displayName = "Mes RDV"))
105 + store.upsert(CalendarBinding(kind = "salle", serverResourceId = "salle-1", displayName = "Salle A"))
106 +
107 + store.clear()
108 +
109 + assertTrue(store.list().isEmpty())
110 + }
111 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/CalendarBridgeTest.kt
+205 -0
@@ -0,0 +1,205 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import android.content.ContentProvider
4 +import android.content.ContentResolver
5 +import android.content.ContentUris
6 +import android.content.ContentValues
7 +import android.content.Context
8 +import android.content.UriMatcher
9 +import android.content.pm.ProviderInfo
10 +import android.database.Cursor
11 +import android.database.MatrixCursor
12 +import android.net.Uri
13 +import android.provider.CalendarContract
14 +import androidx.test.core.app.ApplicationProvider
15 +import org.junit.Assert.assertEquals
16 +import org.junit.Assert.assertTrue
17 +import org.junit.Before
18 +import org.junit.Test
19 +import org.junit.runner.RunWith
20 +import org.robolectric.Robolectric
21 +import org.robolectric.RobolectricTestRunner
22 +import org.robolectric.annotation.Config
23 +
24 +/**
25 + * Couvre [CalendarBridge] au-delà des fonctions pures déjà testées dans
26 + * `CalendarServerIdCodecTest` : `ensureLocalCalendar`/`listEvents`/`upsertEvent`/`deleteEvent`
27 + * via un `ContentProvider` en mémoire ([FakeCalendarProvider]), le vrai fournisseur Agenda
28 + * n'étant pas embarqué dans `android-all` sous Robolectric.
29 + */
30 +@RunWith(RobolectricTestRunner::class)
31 +@Config(sdk = [31])
32 +class CalendarBridgeTest {
33 + private lateinit var bridge: CalendarBridge
34 +
35 + @Before
36 + fun setUp() {
37 + val providerInfo = ProviderInfo().apply { authority = CalendarContract.AUTHORITY }
38 + Robolectric.buildContentProvider(FakeCalendarProvider::class.java).create(providerInfo)
39 +
40 + val resolver = ApplicationProvider.getApplicationContext<Context>().contentResolver
41 + bridge = CalendarBridge(resolver)
42 + }
43 +
44 + @Test
45 + fun ensureLocalCalendarCreatesThenReusesSameCalendar() {
46 + val firstId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
47 + val secondId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
48 +
49 + assertEquals(firstId, secondId)
50 + }
51 +
52 + @Test
53 + fun ensureLocalCalendarCreatesDistinctCalendarsForDistinctNames() {
54 + val rdvId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
55 + val salleId = bridge.ensureLocalCalendar("Card2vcf — Salle A")
56 +
57 + assertTrue(rdvId != salleId)
58 + }
59 +
60 + @Test
61 + fun listEventsOnEmptyCalendarIsEmpty() {
62 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
63 +
64 + assertTrue(bridge.listEvents(calendarId).isEmpty())
65 + }
66 +
67 + @Test
68 + fun upsertEventInsertsThenListEventsReturnsIt() {
69 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
70 + val snapshot = CalendarEventSnapshot(
71 + title = "RDV client",
72 + debutMs = 1_000L,
73 + finMs = 2_000L,
74 + serverId = "rdv-42",
75 + descriptionBody = "notes libres",
76 + )
77 +
78 + val eventId = bridge.upsertEvent(calendarId, snapshot)
79 +
80 + assertEquals(listOf(snapshot.copy(eventId = eventId)), bridge.listEvents(calendarId))
81 + }
82 +
83 + @Test
84 + fun upsertEventWithExistingEventIdUpdatesInPlace() {
85 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
86 + val eventId = bridge.upsertEvent(
87 + calendarId,
88 + CalendarEventSnapshot(title = "RDV client", debutMs = 1_000L, finMs = 2_000L, serverId = "rdv-42"),
89 + )
90 +
91 + val updatedId = bridge.upsertEvent(
92 + calendarId,
93 + CalendarEventSnapshot(
94 + title = "RDV client (déplacé)",
95 + debutMs = 3_000L,
96 + finMs = 4_000L,
97 + eventId = eventId,
98 + serverId = "rdv-42",
99 + ),
100 + )
101 +
102 + assertEquals(eventId, updatedId)
103 + val events = bridge.listEvents(calendarId)
104 + assertEquals(1, events.size)
105 + assertEquals("RDV client (déplacé)", events.single().title)
106 + assertEquals(3_000L, events.single().debutMs)
107 + }
108 +
109 + @Test
110 + fun deleteEventRemovesItFromListEvents() {
111 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
112 + val eventId = bridge.upsertEvent(
113 + calendarId,
114 + CalendarEventSnapshot(title = "RDV client", debutMs = 1_000L, finMs = 2_000L),
115 + )
116 +
117 + bridge.deleteEvent(eventId)
118 +
119 + assertTrue(bridge.listEvents(calendarId).isEmpty())
120 + }
121 +}
122 +
123 +/**
124 + * Fournisseur Agenda minimal en mémoire, pour tester [CalendarBridge] sous Robolectric
125 + * (le vrai `CalendarProvider2` de l'app Agenda n'est pas embarqué dans `android-all`).
126 + * Ne supporte que les projections/sélections utilisées par [CalendarBridge].
127 + */
128 +class FakeCalendarProvider : ContentProvider() {
129 + private val calendars = mutableMapOf<Long, ContentValues>()
130 + private val events = mutableMapOf<Long, ContentValues>()
131 + private var nextId = 1L
132 +
133 + override fun onCreate(): Boolean = true
134 +
135 + override fun getType(uri: Uri): String? = null
136 +
137 + override fun insert(uri: Uri, values: ContentValues?): Uri {
138 + val id = nextId++
139 + val row = ContentValues(values).apply { put("_id", id) }
140 + when (matcher.match(uri)) {
141 + CALENDARS -> calendars[id] = row
142 + EVENTS -> events[id] = row
143 + else -> error("Uri non supportée par FakeCalendarProvider : $uri")
144 + }
145 + return ContentUris.withAppendedId(baseUriFor(matcher.match(uri)), id)
146 + }
147 +
148 + override fun query(
149 + uri: Uri,
150 + projection: Array<out String>?,
151 + selection: String?,
152 + selectionArgs: Array<out String>?,
153 + sortOrder: String?,
154 + ): Cursor {
155 + val table = when (matcher.match(uri)) {
156 + CALENDARS -> calendars
157 + EVENTS -> events
158 + else -> error("Uri non supportée par FakeCalendarProvider : $uri")
159 + }
160 + val columns = projection ?: arrayOf("_id")
161 + val cursor = MatrixCursor(columns)
162 + table.toSortedMap().values
163 + .filter { matchesSelection(it, selection, selectionArgs) }
164 + .forEach { row -> cursor.addRow(columns.map { column -> row.get(column) }) }
165 + return cursor
166 + }
167 +
168 + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int {
169 + val id = ContentUris.parseId(uri)
170 + val row = events[id] ?: return 0
171 + values?.let { row.putAll(it) }
172 + return 1
173 + }
174 +
175 + override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
176 + val id = ContentUris.parseId(uri)
177 + return if (events.remove(id) != null) 1 else 0
178 + }
179 +
180 + private fun baseUriFor(matchCode: Int): Uri = when (matchCode) {
181 + CALENDARS -> Uri.parse("content://${CalendarContract.AUTHORITY}/calendars")
182 + EVENTS -> Uri.parse("content://${CalendarContract.AUTHORITY}/events")
183 + else -> error("Code de correspondance inconnu : $matchCode")
184 + }
185 +
186 + private fun matchesSelection(row: ContentValues, selection: String?, args: Array<out String>?): Boolean {
187 + if (selection.isNullOrBlank()) return true
188 + return selection.split(" AND ").withIndex().all { (index, condition) ->
189 + val column = condition.substringBefore(" = ?").trim()
190 + row.get(column)?.toString() == args?.getOrNull(index)
191 + }
192 + }
193 +
194 + private companion object {
195 + const val CALENDARS = 1
196 + const val EVENTS = 2
197 +
198 + val matcher = UriMatcher(UriMatcher.NO_MATCH).apply {
199 + addURI(CalendarContract.AUTHORITY, "calendars", CALENDARS)
200 + addURI(CalendarContract.AUTHORITY, "calendars/#", CALENDARS)
201 + addURI(CalendarContract.AUTHORITY, "events", EVENTS)
202 + addURI(CalendarContract.AUTHORITY, "events/#", EVENTS)
203 + }
204 + }
205 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/CalendarServerIdCodecTest.kt
+104 -0
@@ -0,0 +1,104 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import org.junit.Assert.assertEquals
4 +import org.junit.Assert.assertNull
5 +import org.junit.Test
6 +
7 +class CalendarServerIdCodecTest {
8 +
9 + @Test
10 + fun encodeWithServerIdPrependsPrefixLine() {
11 + val encoded = CalendarServerIdCodec.encode(serverId = "abc-123", descriptionBody = "RDV client")
12 +
13 + assertEquals("card2vcf:serverId=abc-123\nRDV client", encoded)
14 + }
15 +
16 + @Test
17 + fun encodeWithNullServerIdReturnsBodyUnchanged() {
18 + val encoded = CalendarServerIdCodec.encode(serverId = null, descriptionBody = "RDV client")
19 +
20 + assertEquals("RDV client", encoded)
21 + }
22 +
23 + @Test
24 + fun encodeWithEmptyBodyKeepsTrailingNewline() {
25 + val encoded = CalendarServerIdCodec.encode(serverId = "abc-123", descriptionBody = "")
26 +
27 + assertEquals("card2vcf:serverId=abc-123\n", encoded)
28 + }
29 +
30 + @Test
31 + fun decodeExtractsServerIdAndBody() {
32 + val (serverId, body) = CalendarServerIdCodec.decode("card2vcf:serverId=abc-123\nRDV client")
33 +
34 + assertEquals("abc-123", serverId)
35 + assertEquals("RDV client", body)
36 + }
37 +
38 + @Test
39 + fun decodeWithoutPrefixReturnsNullServerIdAndFullDescription() {
40 + val (serverId, body) = CalendarServerIdCodec.decode("Simple description sans serverId")
41 +
42 + assertNull(serverId)
43 + assertEquals("Simple description sans serverId", body)
44 + }
45 +
46 + @Test
47 + fun decodeNullDescriptionReturnsNullAndEmptyBody() {
48 + val (serverId, body) = CalendarServerIdCodec.decode(null)
49 +
50 + assertNull(serverId)
51 + assertEquals("", body)
52 + }
53 +
54 + @Test
55 + fun decodeWithPrefixButNoNewlineTreatsRestAsServerId() {
56 + val (serverId, body) = CalendarServerIdCodec.decode("card2vcf:serverId=abc-123")
57 +
58 + assertEquals("abc-123", serverId)
59 + assertEquals("", body)
60 + }
61 +
62 + @Test
63 + fun decodeWithMultilineBodyPreservesInternalNewlines() {
64 + val (serverId, body) = CalendarServerIdCodec.decode("card2vcf:serverId=abc-123\nligne1\nligne2")
65 +
66 + assertEquals("abc-123", serverId)
67 + assertEquals("ligne1\nligne2", body)
68 + }
69 +
70 + @Test
71 + fun roundTripEncodeThenDecodePreservesServerIdAndBody() {
72 + val encoded = CalendarServerIdCodec.encode(serverId = "srv-42", descriptionBody = "Détails\nsur plusieurs lignes")
73 + val (serverId, body) = CalendarServerIdCodec.decode(encoded)
74 +
75 + assertEquals("srv-42", serverId)
76 + assertEquals("Détails\nsur plusieurs lignes", body)
77 + }
78 +
79 + @Test
80 + fun roundTripWithoutServerIdPreservesBody() {
81 + val encoded = CalendarServerIdCodec.encode(serverId = null, descriptionBody = "Sans serverId")
82 + val (serverId, body) = CalendarServerIdCodec.decode(encoded)
83 +
84 + assertNull(serverId)
85 + assertEquals("Sans serverId", body)
86 + }
87 +
88 + @Test
89 + fun indispoTitleAddsPrefix() {
90 + assertEquals("[Indispo] Salle A fermée", CalendarEventTitles.indispoTitle("Salle A fermée"))
91 + }
92 +
93 + @Test
94 + fun isIndispoTitleDetectsPrefix() {
95 + assertEquals(true, CalendarEventTitles.isIndispoTitle("[Indispo] Salle A fermée"))
96 + assertEquals(false, CalendarEventTitles.isIndispoTitle("RDV client"))
97 + }
98 +
99 + @Test
100 + fun stripIndispoPrefixRemovesPrefixWhenPresent() {
101 + assertEquals("Salle A fermée", CalendarEventTitles.stripIndispoPrefix("[Indispo] Salle A fermée"))
102 + assertEquals("RDV client", CalendarEventTitles.stripIndispoPrefix("RDV client"))
103 + }
104 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/FakeAilianceApi.kt
+83 -0
@@ -0,0 +1,83 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +/** Fake [AilianceApi] configurable par test ; par défaut tout répond Ok vide. Partagé par [SyncEngineTest] et [AgendaSyncEngineTest]. */
4 +class FakeAilianceApi : AilianceApi {
5 + var statusResult: AilianceApiClient.ApiResult<SyncStatusResponse> =
6 + AilianceApiClient.ApiResult.Ok(SyncStatusResponse("1970-01-01T00:00:00Z", SyncChanges(), 0))
7 + var pullResult: AilianceApiClient.ApiResult<SyncPullResponse> =
8 + AilianceApiClient.ApiResult.Ok(SyncPullResponse(serverTime = "1970-01-01T00:00:00Z"))
9 + var createContactResult: AilianceApiClient.ApiResult<String> =
10 + AilianceApiClient.ApiResult.Ok("""{"id":"srv-generated"}""")
11 + val createContactCalls = mutableListOf<String>()
12 +
13 + var createReservationResult: AilianceApiClient.ApiResult<String> = AilianceApiClient.ApiResult.Ok("""{"id":"resa-generated"}""")
14 + var updateReservationResult: AilianceApiClient.ApiResult<String> = AilianceApiClient.ApiResult.Ok("{}")
15 + val createReservationCalls = mutableListOf<Triple<String, String, String>>()
16 +
17 + var createRdvResult: AilianceApiClient.ApiResult<String> = AilianceApiClient.ApiResult.Ok("""{"id":"rdv-generated"}""")
18 + val createRdvCalls = mutableListOf<String>()
19 +
20 + var statusQueries = mutableListOf<String?>()
21 + var pullQueries = mutableListOf<String?>()
22 +
23 + override fun authCle(nom: String, motDePasse: String) =
24 + AilianceApiClient.ApiResult.Ok(AuthCleResponse(nom, "", "", "argon2id", "xchacha20poly1305"))
25 +
26 + override fun syncStatus(sinceIso: String, ressourcesQuery: String?): AilianceApiClient.ApiResult<SyncStatusResponse> {
27 + statusQueries += ressourcesQuery
28 + return statusResult
29 + }
30 +
31 + override fun syncPull(sinceIso: String, ressourcesQuery: String?): AilianceApiClient.ApiResult<SyncPullResponse> {
32 + pullQueries += ressourcesQuery
33 + return pullResult
34 + }
35 +
36 + override fun createContact(jsonBody: String): AilianceApiClient.ApiResult<String> {
37 + createContactCalls += jsonBody
38 + return createContactResult
39 + }
40 +
41 + override fun updateContact(id: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
42 + override fun deleteContact(id: String) = AilianceApiClient.ApiResult.Ok(Unit)
43 + override fun createEntreprise(jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
44 + override fun updateEntreprise(id: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
45 + override fun deleteEntreprise(id: String) = AilianceApiClient.ApiResult.Ok(Unit)
46 + override fun createProjet(jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
47 + override fun updateProjet(id: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
48 + override fun deleteProjet(id: String) = AilianceApiClient.ApiResult.Ok(Unit)
49 + override fun createTache(projetId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
50 + override fun updateTache(projetId: String, tacheId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
51 + override fun deleteTache(projetId: String, tacheId: String) = AilianceApiClient.ApiResult.Ok(Unit)
52 + override fun moveTache(projetId: String, tacheId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
53 + override fun createInteraction(contactId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
54 + override fun listRdv() = AilianceApiClient.ApiResult.Ok(emptyList<RendezVousDto>())
55 +
56 + override fun createRdv(jsonBody: String): AilianceApiClient.ApiResult<String> {
57 + createRdvCalls += jsonBody
58 + return createRdvResult
59 + }
60 +
61 + override fun getRdv(id: String) = error("not used")
62 + override fun updateRdv(id: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
63 + override fun deleteRdv(id: String) = AilianceApiClient.ApiResult.Ok(Unit)
64 + override fun getRessourcesCatalogue() = AilianceApiClient.ApiResult.Ok(RessourcesCatalogueDto())
65 + override fun listReservations(genre: String, resourceId: String) = AilianceApiClient.ApiResult.Ok(emptyList<ReservationDto>())
66 +
67 + override fun createReservation(genre: String, resourceId: String, jsonBody: String): AilianceApiClient.ApiResult<String> {
68 + createReservationCalls += Triple(genre, resourceId, jsonBody)
69 + return createReservationResult
70 + }
71 +
72 + override fun updateReservation(genre: String, resourceId: String, reservationId: String, jsonBody: String) =
73 + updateReservationResult
74 +
75 + val deleteReservationCalls = mutableListOf<Triple<String, String, String>>()
76 +
77 + override fun deleteReservation(genre: String, resourceId: String, reservationId: String): AilianceApiClient.ApiResult<Unit> {
78 + deleteReservationCalls += Triple(genre, resourceId, reservationId)
79 + return AilianceApiClient.ApiResult.Ok(Unit)
80 + }
81 + override fun listIndisponibilites(genre: String, resourceId: String) =
82 + AilianceApiClient.ApiResult.Ok(emptyList<IndisponibiliteDto>())
83 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/FakeCalendarBridge.kt
+31 -0
@@ -0,0 +1,31 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +/** Fake [CalendarBridgeApi] en mémoire (sans `ContentResolver`) pour tester le pont Agenda↔Room. */
4 +class FakeCalendarBridge : CalendarBridgeApi {
5 + private val calendars = mutableMapOf<String, Long>()
6 + private val events = mutableMapOf<Long, MutableMap<Long, CalendarEventSnapshot>>()
7 + private var nextCalendarId = 1L
8 + private var nextEventId = 1L
9 + val deletedEventIds = mutableListOf<Long>()
10 +
11 + override fun ensureLocalCalendar(displayName: String): Long =
12 + calendars.getOrPut(displayName) { nextCalendarId++ }
13 +
14 + override fun listEvents(calendarId: Long): List<CalendarEventSnapshot> =
15 + events[calendarId]?.values?.toList() ?: emptyList()
16 +
17 + override fun upsertEvent(calendarId: Long, snap: CalendarEventSnapshot): Long {
18 + val calendarEvents = events.getOrPut(calendarId) { mutableMapOf() }
19 + val eventId = snap.eventId ?: nextEventId++
20 + calendarEvents[eventId] = snap.copy(eventId = eventId)
21 + return eventId
22 + }
23 +
24 + override fun deleteEvent(eventId: Long) {
25 + deletedEventIds += eventId
26 + events.values.forEach { it.remove(eventId) }
27 + }
28 +
29 + /** Simule un événement déjà présent dans l'app Agenda système (ajout hors [upsertEvent]). */
30 + fun seedEvent(calendarId: Long, snap: CalendarEventSnapshot): Long = upsertEvent(calendarId, snap)
31 +}
M android/app/src/test/java/fr/ebii/card2vcf/sync/SyncEngineTest.kt
+0 -37
@@ -16,43 +16,6 @@ import org.junit.runner.RunWith
16 16 import org.robolectric.RobolectricTestRunner
17 17 import org.robolectric.annotation.Config
18 18
19 -/** Fake [AilianceApi] configurable par test ; par défaut tout répond Ok vide. */
20 -private class FakeAilianceApi : AilianceApi {
21 - var statusResult: AilianceApiClient.ApiResult<SyncStatusResponse> =
22 - AilianceApiClient.ApiResult.Ok(SyncStatusResponse("1970-01-01T00:00:00Z", SyncChanges(), 0))
23 - var pullResult: AilianceApiClient.ApiResult<SyncPullResponse> =
24 - AilianceApiClient.ApiResult.Ok(SyncPullResponse(serverTime = "1970-01-01T00:00:00Z"))
25 - var createContactResult: AilianceApiClient.ApiResult<String> =
26 - AilianceApiClient.ApiResult.Ok("""{"id":"srv-generated"}""")
27 - val createContactCalls = mutableListOf<String>()
28 -
29 - override fun authCle(nom: String, motDePasse: String) =
30 - AilianceApiClient.ApiResult.Ok(AuthCleResponse(nom, "", "", "argon2id", "xchacha20poly1305"))
31 -
32 - override fun syncStatus(sinceIso: String) = statusResult
33 -
34 - override fun syncPull(sinceIso: String) = pullResult
35 -
36 - override fun createContact(jsonBody: String): AilianceApiClient.ApiResult<String> {
37 - createContactCalls += jsonBody
38 - return createContactResult
39 - }
40 -
41 - override fun updateContact(id: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
42 - override fun deleteContact(id: String) = AilianceApiClient.ApiResult.Ok(Unit)
43 - override fun createEntreprise(jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
44 - override fun updateEntreprise(id: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
45 - override fun deleteEntreprise(id: String) = AilianceApiClient.ApiResult.Ok(Unit)
46 - override fun createProjet(jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
47 - override fun updateProjet(id: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
48 - override fun deleteProjet(id: String) = AilianceApiClient.ApiResult.Ok(Unit)
49 - override fun createTache(projetId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
50 - override fun updateTache(projetId: String, tacheId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
51 - override fun deleteTache(projetId: String, tacheId: String) = AilianceApiClient.ApiResult.Ok(Unit)
52 - override fun moveTache(projetId: String, tacheId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
53 - override fun createInteraction(contactId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
54 -}
55 -
56 19 @RunWith(RobolectricTestRunner::class)
57 20 @Config(sdk = [31])
58 21 class SyncEngineTest {
M android/app/src/test/java/fr/ebii/card2vcf/ui/settings/SettingsViewModelTest.kt
+177 -19
@@ -2,9 +2,14 @@ package fr.ebii.card2vcf.ui.settings
2 2
3 3 import android.content.Context
4 4 import androidx.test.core.app.ApplicationProvider
5 +import fr.ebii.card2vcf.sync.AgendaSyncCoordinator
5 6 import fr.ebii.card2vcf.sync.AilianceApi
6 7 import fr.ebii.card2vcf.sync.AilianceApiClient
7 8 import fr.ebii.card2vcf.sync.AuthCleResponse
9 +import fr.ebii.card2vcf.sync.CalendarBindingsStore
10 +import fr.ebii.card2vcf.sync.FakeCalendarBridge
11 +import fr.ebii.card2vcf.sync.RessourceItemDto
12 +import fr.ebii.card2vcf.sync.RessourcesCatalogueDto
8 13 import fr.ebii.card2vcf.sync.SyncCredentialsStore
9 14 import kotlinx.coroutines.Dispatchers
10 15 import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -15,6 +20,8 @@ import kotlinx.coroutines.test.runTest
15 20 import kotlinx.coroutines.test.setMain
16 21 import org.junit.After
17 22 import org.junit.Assert.assertEquals
23 +import org.junit.Assert.assertFalse
24 +import org.junit.Assert.assertNull
18 25 import org.junit.Assert.assertTrue
19 26 import org.junit.Before
20 27 import org.junit.Test
@@ -22,13 +29,16 @@ import org.junit.runner.RunWith
22 29 import org.robolectric.RobolectricTestRunner
23 30 import org.robolectric.annotation.Config
24 31
25 -/** Fake [AilianceApi] : seule `authCle` importe pour [SettingsViewModel]. */
32 +/** Fake [AilianceApi] : `authCle` (login) et `getRessourcesCatalogue` (section calendriers) importent pour [SettingsViewModel]. */
26 33 private class FakeAilianceApi(
27 - private val authResult: AilianceApiClient.ApiResult<AuthCleResponse>,
34 + private val authResult: AilianceApiClient.ApiResult<AuthCleResponse> =
35 + AilianceApiClient.ApiResult.Err(-1, "authCle not stubbed for this test"),
36 + private val catalogueResult: AilianceApiClient.ApiResult<RessourcesCatalogueDto> =
37 + AilianceApiClient.ApiResult.Ok(RessourcesCatalogueDto()),
28 38 ) : AilianceApi {
29 39 override fun authCle(nom: String, motDePasse: String) = authResult
30 - override fun syncStatus(sinceIso: String) = error("not used")
31 - override fun syncPull(sinceIso: String) = error("not used")
40 + override fun syncStatus(sinceIso: String, ressourcesQuery: String?) = error("not used")
41 + override fun syncPull(sinceIso: String, ressourcesQuery: String?) = error("not used")
32 42 override fun createContact(jsonBody: String) = error("not used")
33 43 override fun updateContact(id: String, jsonBody: String) = error("not used")
34 44 override fun deleteContact(id: String) = error("not used")
@@ -43,6 +53,17 @@ private class FakeAilianceApi(
43 53 override fun deleteTache(projetId: String, tacheId: String) = error("not used")
44 54 override fun moveTache(projetId: String, tacheId: String, jsonBody: String) = error("not used")
45 55 override fun createInteraction(contactId: String, jsonBody: String) = error("not used")
56 + override fun listRdv() = error("not used")
57 + override fun createRdv(jsonBody: String) = error("not used")
58 + override fun getRdv(id: String) = error("not used")
59 + override fun updateRdv(id: String, jsonBody: String) = error("not used")
60 + override fun deleteRdv(id: String) = error("not used")
61 + override fun getRessourcesCatalogue() = catalogueResult
62 + override fun listReservations(genre: String, resourceId: String) = error("not used")
63 + override fun createReservation(genre: String, resourceId: String, jsonBody: String) = error("not used")
64 + override fun updateReservation(genre: String, resourceId: String, reservationId: String, jsonBody: String) = error("not used")
65 + override fun deleteReservation(genre: String, resourceId: String, reservationId: String) = error("not used")
66 + override fun listIndisponibilites(genre: String, resourceId: String) = error("not used")
46 67 }
47 68
48 69 @OptIn(ExperimentalCoroutinesApi::class)
@@ -51,6 +72,8 @@ private class FakeAilianceApi(
51 72 class SettingsViewModelTest {
52 73 private val testDispatcher = StandardTestDispatcher()
53 74 private lateinit var store: SyncCredentialsStore
75 + private lateinit var calendarBindingsStore: CalendarBindingsStore
76 + private lateinit var calendarBridge: FakeCalendarBridge
54 77
55 78 @Before
56 79 fun setUp() {
@@ -59,6 +82,10 @@ class SettingsViewModelTest {
59 82 val prefs = ctx.getSharedPreferences("settings_vm_test", Context.MODE_PRIVATE)
60 83 prefs.edit().clear().apply()
61 84 store = SyncCredentialsStore(prefs)
85 + val bindingsPrefs = ctx.getSharedPreferences("settings_vm_test_bindings", Context.MODE_PRIVATE)
86 + bindingsPrefs.edit().clear().apply()
87 + calendarBindingsStore = CalendarBindingsStore(bindingsPrefs)
88 + calendarBridge = FakeCalendarBridge()
62 89 }
63 90
64 91 @After
@@ -66,6 +93,18 @@ class SettingsViewModelTest {
66 93 Dispatchers.resetMain()
67 94 }
68 95
96 + private fun newViewModel(
97 + catalogueResult: AilianceApiClient.ApiResult<RessourcesCatalogueDto> = AilianceApiClient.ApiResult.Ok(RessourcesCatalogueDto()),
98 + authResult: AilianceApiClient.ApiResult<AuthCleResponse> = AilianceApiClient.ApiResult.Err(-1, "authCle not stubbed for this test"),
99 + ) = SettingsViewModel(
100 + credentialsStore = store,
101 + calendarBindingsStore = calendarBindingsStore,
102 + calendarBridge = calendarBridge,
103 + apiFactory = { FakeAilianceApi(authResult = authResult) },
104 + authenticatedApiFactory = { _, _ -> FakeAilianceApi(catalogueResult = catalogueResult) },
105 + ioDispatcher = testDispatcher,
106 + )
107 +
69 108 @Test
70 109 fun obtainKeyThenRetype_savesCredentials() = runTest(testDispatcher) {
71 110 val authResponse = AuthCleResponse(
@@ -75,11 +114,7 @@ class SettingsViewModelTest {
75 114 kdf = "argon2id",
76 115 cipher = "xchacha20poly1305",
77 116 )
78 - val vm = SettingsViewModel(
79 - credentialsStore = store,
80 - apiFactory = { FakeAilianceApi(AilianceApiClient.ApiResult.Ok(authResponse)) },
81 - ioDispatcher = testDispatcher,
82 - )
117 + val vm = newViewModel(authResult = AilianceApiClient.ApiResult.Ok(authResponse))
83 118
84 119 vm.setBaseUrl("https://api.example.com")
85 120 vm.setUserName("alice")
@@ -104,11 +139,7 @@ class SettingsViewModelTest {
104 139
105 140 @Test
106 141 fun obtainKey_invalidCredentials_showsInkError() = runTest(testDispatcher) {
107 - val vm = SettingsViewModel(
108 - credentialsStore = store,
109 - apiFactory = { FakeAilianceApi(AilianceApiClient.ApiResult.Err(401, "unauthorized")) },
110 - ioDispatcher = testDispatcher,
111 - )
142 + val vm = newViewModel(authResult = AilianceApiClient.ApiResult.Err(401, "unauthorized"))
112 143
113 144 vm.setBaseUrl("https://api.example.com")
114 145 vm.setUserName("alice")
@@ -130,11 +161,7 @@ class SettingsViewModelTest {
130 161 kdf = "argon2id",
131 162 cipher = "xchacha20poly1305",
132 163 )
133 - val vm = SettingsViewModel(
134 - credentialsStore = store,
135 - apiFactory = { FakeAilianceApi(AilianceApiClient.ApiResult.Ok(authResponse)) },
136 - ioDispatcher = testDispatcher,
137 - )
164 + val vm = newViewModel(authResult = AilianceApiClient.ApiResult.Ok(authResponse))
138 165 vm.setBaseUrl("https://api.example.com")
139 166 vm.setUserName("alice")
140 167 vm.setPassword(FIXTURE_MDP)
@@ -150,6 +177,137 @@ class SettingsViewModelTest {
150 177 assertTrue(!store.isConfigured())
151 178 }
152 179
180 + // ---- Task M6 : Calendriers ----
181 +
182 + private fun loggedInViewModel(
183 + catalogueResult: AilianceApiClient.ApiResult<RessourcesCatalogueDto> = AilianceApiClient.ApiResult.Ok(RessourcesCatalogueDto()),
184 + ): SettingsViewModel {
185 + store.save(baseUrl = "https://api.example.com", apiKey = "sk_fixture", userName = "alice")
186 + return newViewModel(catalogueResult = catalogueResult)
187 + }
188 +
189 + @Test
190 + fun onCalendarPermissionChanged_granted_loadsRessourcesCatalogue() = runTest(testDispatcher) {
191 + val catalogue = RessourcesCatalogueDto(
192 + salles = listOf(RessourceItemDto(id = "s1", nom = "A", actif = true)),
193 + )
194 + val vm = loggedInViewModel(catalogueResult = AilianceApiClient.ApiResult.Ok(catalogue))
195 +
196 + vm.onCalendarPermissionChanged(true)
197 + advanceUntilIdle()
198 +
199 + val state = vm.state.value as SettingsUiState.LoggedIn
200 + assertTrue(state.calendarPermissionGranted)
201 + assertEquals(1, state.ressources.size)
202 + assertEquals("s1", state.ressources.single().serverResourceId)
203 + assertFalse(state.catalogueLoading)
204 + }
205 +
206 + @Test
207 + fun onCalendarPermissionChanged_denied_doesNotFetchCatalogue() = runTest(testDispatcher) {
208 + val vm = loggedInViewModel(catalogueResult = AilianceApiClient.ApiResult.Err(500, "should not be called"))
209 +
210 + vm.onCalendarPermissionChanged(false)
211 + advanceUntilIdle()
212 +
213 + val state = vm.state.value as SettingsUiState.LoggedIn
214 + assertFalse(state.calendarPermissionGranted)
215 + assertTrue(state.ressources.isEmpty())
216 + assertNull(state.catalogueError)
217 + }
218 +
219 + @Test
220 + fun setMesRdvEnabled_true_createsCalendarAndUpsertsBinding() = runTest(testDispatcher) {
221 + val vm = loggedInViewModel()
222 +
223 + vm.setMesRdvEnabled(true)
224 + advanceUntilIdle()
225 +
226 + val state = vm.state.value as SettingsUiState.LoggedIn
227 + assertTrue(state.mesRdvLinked)
228 + val binding = calendarBindingsStore.list().single()
229 + assertEquals(AgendaSyncCoordinator.KIND_RDV, binding.kind)
230 + assertEquals("Card2vcf — Mes RDV", binding.displayName)
231 + assertTrue(binding.androidCalendarId != null)
232 + }
233 +
234 + @Test
235 + fun setMesRdvEnabled_false_setsPendingRemovalWithoutClearingBindingYet() = runTest(testDispatcher) {
236 + val vm = loggedInViewModel()
237 + vm.setMesRdvEnabled(true)
238 + advanceUntilIdle()
239 +
240 + vm.setMesRdvEnabled(false)
241 +
242 + val state = vm.state.value as SettingsUiState.LoggedIn
243 + assertEquals(AgendaSyncCoordinator.KIND_RDV, state.pendingRemoval?.kind)
244 + assertTrue(state.mesRdvLinked) // pas encore retiré : attend confirmation
245 + assertEquals(1, calendarBindingsStore.list().size)
246 + }
247 +
248 + @Test
249 + fun confirmRemoveBinding_removesMesRdvBinding() = runTest(testDispatcher) {
250 + val vm = loggedInViewModel()
251 + vm.setMesRdvEnabled(true)
252 + advanceUntilIdle()
253 + vm.setMesRdvEnabled(false)
254 +
255 + vm.confirmRemoveBinding()
256 +
257 + val state = vm.state.value as SettingsUiState.LoggedIn
258 + assertFalse(state.mesRdvLinked)
259 + assertNull(state.pendingRemoval)
260 + assertTrue(calendarBindingsStore.list().isEmpty())
261 + }
262 +
263 + @Test
264 + fun dismissRemoveBinding_keepsBindingAndClearsDialog() = runTest(testDispatcher) {
265 + val vm = loggedInViewModel()
266 + vm.setMesRdvEnabled(true)
267 + advanceUntilIdle()
268 + vm.setMesRdvEnabled(false)
269 +
270 + vm.dismissRemoveBinding()
271 +
272 + val state = vm.state.value as SettingsUiState.LoggedIn
273 + assertTrue(state.mesRdvLinked)
274 + assertNull(state.pendingRemoval)
275 + assertEquals(1, calendarBindingsStore.list().size)
276 + }
277 +
278 + @Test
279 + fun setRessourceEnabled_true_createsCalendarNamedFromCatalogue() = runTest(testDispatcher) {
280 + val catalogue = RessourcesCatalogueDto(salles = listOf(RessourceItemDto(id = "s1", nom = "A", actif = true)))
281 + val vm = loggedInViewModel(catalogueResult = AilianceApiClient.ApiResult.Ok(catalogue))
282 + vm.onCalendarPermissionChanged(true)
283 + advanceUntilIdle()
284 +
285 + vm.setRessourceEnabled(kind = "salle", serverResourceId = "s1", enabled = true)
286 + advanceUntilIdle()
287 +
288 + val state = vm.state.value as SettingsUiState.LoggedIn
289 + assertTrue(state.ressources.single().linked)
290 + val binding = calendarBindingsStore.list().single()
291 + assertEquals("salle", binding.kind)
292 + assertEquals("s1", binding.serverResourceId)
293 + assertEquals("Card2vcf — Salle A", binding.displayName)
294 + }
295 +
296 + @Test
297 + fun setRessourceEnabled_onInactiveRessource_isNoOp() = runTest(testDispatcher) {
298 + val catalogue = RessourcesCatalogueDto(salles = listOf(RessourceItemDto(id = "s1", nom = "A", actif = false)))
299 + val vm = loggedInViewModel(catalogueResult = AilianceApiClient.ApiResult.Ok(catalogue))
300 + vm.onCalendarPermissionChanged(true)
301 + advanceUntilIdle()
302 +
303 + vm.setRessourceEnabled(kind = "salle", serverResourceId = "s1", enabled = true)
304 + advanceUntilIdle()
305 +
306 + val state = vm.state.value as SettingsUiState.LoggedIn
307 + assertFalse(state.ressources.single().linked)
308 + assertTrue(calendarBindingsStore.list().isEmpty())
309 + }
310 +
153 311 private companion object {
154 312 const val FIXTURE_MDP = "card2vcf-fixture-mdp"
155 313 const val FIXTURE_CLE = "sk_fixture_aabbccddeeff00112233"
A android/app/src/test/java/fr/ebii/card2vcf/ui/sync/SyncChromeViewModelTest.kt
+179 -0
@@ -0,0 +1,179 @@
1 +package fr.ebii.card2vcf.ui.sync
2 +
3 +import android.content.Context
4 +import androidx.room.Room
5 +import androidx.test.core.app.ApplicationProvider
6 +import fr.ebii.card2vcf.data.CrmDatabase
7 +import fr.ebii.card2vcf.data.RdvEntity
8 +import fr.ebii.card2vcf.data.ReservationEntity
9 +import fr.ebii.card2vcf.sync.AilianceApiClient
10 +import fr.ebii.card2vcf.sync.CalendarBindingsStore
11 +import fr.ebii.card2vcf.sync.FakeAilianceApi
12 +import fr.ebii.card2vcf.sync.FakeCalendarBridge
13 +import fr.ebii.card2vcf.sync.SyncCredentialsStore
14 +import fr.ebii.card2vcf.sync.SyncEngine
15 +import fr.ebii.card2vcf.sync.SyncOpEntity
16 +import kotlinx.coroutines.Dispatchers
17 +import kotlinx.coroutines.ExperimentalCoroutinesApi
18 +import kotlinx.coroutines.runBlocking
19 +import kotlinx.coroutines.test.resetMain
20 +import kotlinx.coroutines.test.setMain
21 +import org.junit.After
22 +import org.junit.Assert.assertEquals
23 +import org.junit.Assert.assertNull
24 +import org.junit.Assert.assertTrue
25 +import org.junit.Before
26 +import org.junit.Test
27 +import org.junit.runner.RunWith
28 +import org.robolectric.RobolectricTestRunner
29 +import org.robolectric.annotation.Config
30 +
31 +/**
32 + * Task M7 : bandeau « calendrier local en avance » + dialog conflit réservation (409).
33 + *
34 + * [SyncEngine] fixe `Dispatchers.IO` en interne (voir `SyncEngine.kt`), incompatible avec le temps
35 + * virtuel de `runTest`/`StandardTestDispatcher`. On utilise donc `Dispatchers.Unconfined` comme
36 + * dispatcher `Main` et on attend les effets par scrutation ([awaitUntil]) plutôt que par
37 + * `advanceUntilIdle`, comme le fait déjà `AgendaSyncEngineTest` (`runBlocking` direct sur l'engine).
38 + */
39 +@OptIn(ExperimentalCoroutinesApi::class)
40 +@RunWith(RobolectricTestRunner::class)
41 +@Config(sdk = [31])
42 +class SyncChromeViewModelTest {
43 + private lateinit var db: CrmDatabase
44 + private lateinit var api: FakeAilianceApi
45 + private lateinit var bridge: FakeCalendarBridge
46 + private lateinit var bindingsStore: CalendarBindingsStore
47 + private lateinit var credentialsStore: SyncCredentialsStore
48 +
49 + @Before
50 + fun setUp() {
51 + Dispatchers.setMain(Dispatchers.Unconfined)
52 + val ctx = ApplicationProvider.getApplicationContext<Context>()
53 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java).allowMainThreadQueries().build()
54 + api = FakeAilianceApi()
55 + bridge = FakeCalendarBridge()
56 + val bindingsPrefs = ctx.getSharedPreferences("sync_chrome_vm_test_bindings", Context.MODE_PRIVATE)
57 + bindingsPrefs.edit().clear().apply()
58 + bindingsStore = CalendarBindingsStore(bindingsPrefs)
59 + val credsPrefs = ctx.getSharedPreferences("sync_chrome_vm_test_creds", Context.MODE_PRIVATE)
60 + credsPrefs.edit().clear().apply()
61 + credentialsStore = SyncCredentialsStore(credsPrefs)
62 + credentialsStore.save(baseUrl = "https://api.example.com", apiKey = "sk_fixture", userName = "alice")
63 + }
64 +
65 + @After
66 + fun tearDown() {
67 + Dispatchers.resetMain()
68 + db.close()
69 + }
70 +
71 + private fun newViewModel() = SyncChromeViewModel(
72 + credentialsStore = credentialsStore,
73 + calendarBindingsStore = bindingsStore,
74 + calendarBridge = bridge,
75 + engineFactory = { SyncEngine(api, db) },
76 + )
77 +
78 + private fun awaitUntil(timeoutMs: Long = 3000, condition: () -> Boolean) {
79 + val deadline = System.currentTimeMillis() + timeoutMs
80 + while (!condition() && System.currentTimeMillis() < deadline) {
81 + Thread.sleep(5)
82 + }
83 + assertTrue("Condition non atteinte sous ${timeoutMs}ms", condition())
84 + }
85 +
86 + @Test
87 + fun refreshStatus_populatesLocalCalendarAheadFromDirtyRdv() = runBlocking {
88 + db.rdvDao().upsert(RdvEntity(titre = "Dirty", dirtyLocal = true))
89 + val vm = newViewModel()
90 +
91 + vm.refreshStatus()
92 +
93 + awaitUntil { vm.localCalendarAhead.value == 1 }
94 + }
95 +
96 + @Test
97 + fun refreshStatus_passesRessourcesQueryFromBindings() {
98 + bindingsStore.upsert(
99 + fr.ebii.card2vcf.sync.CalendarBinding(
100 + kind = "salle",
101 + serverResourceId = "s1",
102 + displayName = "Card2vcf — Salle A",
103 + androidCalendarId = 1L,
104 + ),
105 + )
106 + val vm = newViewModel()
107 +
108 + vm.refreshStatus()
109 +
110 + awaitUntil { api.statusQueries.isNotEmpty() }
111 + assertEquals("salle:s1", api.statusQueries.last())
112 + }
113 +
114 + @Test
115 + fun syncNow_success_refreshesLocalCalendarAheadToZeroWhenClean() {
116 + val vm = newViewModel()
117 +
118 + vm.syncNow()
119 +
120 + awaitUntil { !vm.syncing.value }
121 + assertEquals(0, vm.localCalendarAhead.value)
122 + assertNull(vm.conflictPending.value)
123 + }
124 +
125 + @Test
126 + fun syncNow_withReservationConflict_setsConflictPending() = runBlocking {
127 + val localId = db.reservationDao().upsert(
128 + ReservationEntity(cibleType = "salle", cibleId = "salle-1", motif = "Réunion", dirtyLocal = true),
129 + )
130 + db.syncOpDao().insert(
131 + SyncOpEntity(entityType = "reservation", op = "create", localId = localId, createdAt = 1L),
132 + )
133 + api.createReservationResult = AilianceApiClient.ApiResult.Err(409, "Chevauchement")
134 + val vm = newViewModel()
135 +
136 + vm.syncNow()
137 +
138 + awaitUntil { vm.conflictPending.value != null }
139 + assertEquals(localId, vm.conflictPending.value?.localReservationId)
140 + }
141 +
142 + @Test
143 + fun confirmConflictCancellation_removesReservationAndClearsDialog() = runBlocking {
144 + val localId = db.reservationDao().upsert(
145 + ReservationEntity(cibleType = "salle", cibleId = "salle-1", motif = "Réunion", dirtyLocal = true),
146 + )
147 + db.syncOpDao().insert(
148 + SyncOpEntity(entityType = "reservation", op = "create", localId = localId, createdAt = 1L),
149 + )
150 + api.createReservationResult = AilianceApiClient.ApiResult.Err(409, "Chevauchement")
151 + val vm = newViewModel()
152 + vm.syncNow()
153 + awaitUntil { vm.conflictPending.value != null }
154 +
155 + vm.confirmConflictCancellation()
156 + awaitUntil { vm.conflictPending.value == null }
157 +
158 + assertNull(db.reservationDao().getByLocalId(localId))
159 + }
160 +
161 + @Test
162 + fun dismissConflict_clearsDialogButKeepsConflictPendingEntity() = runBlocking {
163 + val localId = db.reservationDao().upsert(
164 + ReservationEntity(cibleType = "salle", cibleId = "salle-1", motif = "Réunion", dirtyLocal = true),
165 + )
166 + db.syncOpDao().insert(
167 + SyncOpEntity(entityType = "reservation", op = "create", localId = localId, createdAt = 1L),
168 + )
169 + api.createReservationResult = AilianceApiClient.ApiResult.Err(409, "Chevauchement")
170 + val vm = newViewModel()
171 + vm.syncNow()
172 + awaitUntil { vm.conflictPending.value != null }
173 +
174 + vm.dismissConflict()
175 +
176 + assertNull(vm.conflictPending.value)
177 + assertTrue(db.reservationDao().getByLocalId(localId)?.conflictPending == true)
178 + }
179 +}
A docs/plans/sync_agenda_rdv_ressources_v2.md
+433 -0
@@ -0,0 +1,433 @@
1 +# Sync Agenda v2 — Plan d’implémentation (serveur puis mobile)
2 +
3 +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. TDD. **Ne pas committer** sauf demande explicite de l’utilisateur.
4 +
5 +**Goal :** Exposer RDV + réservations/indispos en API/sync Projectiaon, puis pont Card2vcf Room ↔ Agenda Android multi-calendriers (Mes RDV + ressources sélectionnées), sans écran agenda in-app.
6 +
7 +**Architecture :** Étendre sync v1 (`tombstones`, `status`/`pull`, SyncOp, LWW). Serveur d’abord. Mobile : Paramètres crée/lie des calendriers système ; event ressource = réservation Active ; 409 → refresh + dialog annulation.
8 +
9 +**Tech Stack :** Rust/Axum (Projectiaon) ; Kotlin/Room/CalendarContract/OkHttp (Card2vcf).
10 +
11 +**Spec :** [`docs/superpowers/specs/2026-07-22-sync-agenda-rdv-ressources-v2-design.md`](../superpowers/specs/2026-07-22-sync-agenda-rdv-ressources-v2-design.md)
12 +
13 +**Hors scope :** UI demande/réclamation mobile ; SyncAdapter ; calendriers cloud GMS ; écran agenda Card2vcf.
14 +
15 +---
16 +
17 +## Fichiers (carte)
18 +
19 +### Serveur (Projectiaon)
20 +
21 +| Fichier | Rôle |
22 +|---|---|
23 +| `src/modeles.rs` | `mis_a_jour_le` sur `RendezVous`, `Reservation`, `Indisponibilite` |
24 +| `src/handlers/rdv.rs` + chemins UI | Poser `mis_a_jour_le` à chaque mutation HTML |
25 +| `src/services/ressources.rs` | Idem résa/indispo ; mapper conflit → signal API 409 |
26 +| `src/handlers/api.rs` | Routes `/api/rdv`, `/api/ressources*`, OpenAPI |
27 +| `src/services/sync.rs` | Delta rdv/reservations/indisponibilites + tombstones + query `ressources` |
28 +| `src/services/sync.rs` `TypeTombstone` | Variantes `Rdv`, `Reservation`, `Indisponibilite` |
29 +| `tests/api_agenda_http.rs` | Tests HTTP Bearer CRUD + sync + 409 |
30 +| `docs/guide-administrateur.md` | Doc endpoints agenda mobile |
31 +
32 +### Mobile (EBII_mobileVCF)
33 +
34 +| Fichier | Rôle |
35 +|---|---|
36 +| `AndroidManifest.xml` | `READ_CALENDAR`, `WRITE_CALENDAR` |
37 +| `sync/CalendarBindingsStore.kt` | Prefs calendriers liés |
38 +| `sync/CalendarBridge.kt` | CRUD events / calendriers locaux |
39 +| `data/RdvEntity.kt` + Dao | Room RDV |
40 +| `data/ReservationEntity.kt` + Dao | Room résas |
41 +| `data/IndisponibiliteEntity.kt` + Dao | Room indispos lecture |
42 +| `CrmDatabase.kt` | version ↑ (3), entities |
43 +| `sync/SyncModels.kt` + `AilianceApi*` | DTOs + méthodes API |
44 +| `sync/SyncEngine.kt` | Agenda↔Room↔API, conflit 409 |
45 +| `ui/settings/*` | Section calendriers |
46 +| `ui/sync/SyncBanner.kt` + ViewModels | Bandeau « local en avance » + dialog conflit |
47 +| Tests unitaires | Bridge mapping, SyncEngine conflit, bindings |
48 +
49 +---
50 +
51 +# Partie A — Serveur
52 +
53 +## Task S1 — `mis_a_jour_le` sur RDV / Reservation / Indisponibilite
54 +
55 +**Files :**
56 +- Modify: `Projectiaon/src/modeles.rs` (`RendezVous`, `Reservation`, `Indisponibilite`)
57 +- Modify: handlers/services qui écrivent ces entités (`handlers/rdv.rs`, `services/rdv.rs`, `services/ressources.rs`, `handlers/ressources.rs`, `handlers/planning.rs` si besoin)
58 +- Test: étendre tests unitaires existants ressources/rdv ou nouveau test modèle
59 +
60 +- [ ] **Step 1 :** Ajouter aux 3 structs :
61 +
62 +```rust
63 +#[serde(default)]
64 +pub mis_a_jour_le: Option<DateTime<Utc>>,
65 +```
66 +
67 +- [ ] **Step 2 :** Helper `fn toucher_horodatage(m: &mut Option<DateTime<Utc>>)` ou poser `Some(Utc::now())` à chaque `ecrire_*` create/update (et avant delete pour tombstone timestamp = now).
68 +- [ ] **Step 3 :** Migration serde : fichiers JSON existants sans champ → `None` OK (`#[serde(default)]`).
69 +- [ ] **Verify :** `cargo test -p ailiance-brain --lib` (filtres rdv/ressources si présents) vert.
70 +
71 +---
72 +
73 +## Task S2 — Tombstones + helpers list-since
74 +
75 +**Files :**
76 +- Modify: `src/services/sync.rs` (`TypeTombstone`, `CompteursSync`, `PullSync`, `status`, `pull`)
77 +- Modify: tous les `supprimer_rendezvous` / `supprimer_reservation` / `supprimer_indisponibilite` chemins API+UI critiques → `enregistrer_tombstone` **avant** hard delete
78 +
79 +Schéma type JSON inchangé :
80 +
81 +```json
82 +{ "type": "rdv|reservation|indisponibilite", "id": "…", "projet_id": null, "supprime_le": "…" }
83 +```
84 +
85 +Pour résa/indispo, stocker aussi la cible dans un champ optionnel si utile au client :
86 +
87 +```json
88 +{ "type": "reservation", "id": "…", "cible_type": "salle", "cible_id": "abc", "supprime_le": "…" }
89 +```
90 +
91 +(`cible_type`/`cible_id` : `#[serde(default)]` pour ne pas casser tombstones v1.)
92 +
93 +- [ ] Étendre `Tombstone` + `TypeTombstone`.
94 +- [ ] Brancher deletes RDV/résa/indispo (au minimum chemins qui seront exposés API ; idéalement UI aussi).
95 +- [ ] **Verify :** test unitaire `tombstones_depuis` filtre les nouveaux types.
96 +
97 +---
98 +
99 +## Task S3 — API CRUD `/api/rdv`
100 +
101 +**Files :**
102 +- Modify: `src/handlers/api.rs`
103 +- Test: `tests/api_agenda_http.rs` (créer)
104 +
105 +Contrat :
106 +
107 +```http
108 +GET /api/rdv → [RendezVous] # option: ?mine=1 défaut pour mobile = filtre utilisateur==auteur clé
109 +POST /api/rdv → 201 RendezVous
110 +GET /api/rdv/:id
111 +PUT /api/rdv/:id
112 +DELETE /api/rdv/:id → 204 + tombstone
113 +```
114 +
115 +Body create/update (snake_case) :
116 +
117 +```json
118 +{
119 + "titre": "…",
120 + "description": "",
121 + "lieu": "",
122 + "debut": "ISO-8601",
123 + "fin": "ISO-8601",
124 + "contact_ids": [],
125 + "projet_id": null
126 +}
127 +```
128 +
129 +Règles :
130 +- `utilisateur` / `cree_par` = user Bearer (ignorer spoof).
131 +- `peut_modifier` pour PUT/DELETE.
132 +- Valider via `RendezVous::valider`.
133 +- Poser `mis_a_jour_le`.
134 +
135 +- [ ] **RED :** test HTTP create → get → auteur = user clé ; mauvaise clé 401 ; autre user ne peut pas DELETE.
136 +- [ ] **GREEN :** handlers + routes dans le routeur Bearer (pas `/auth/cle`).
137 +- [ ] OpenAPI : documenter les 5 routes.
138 +- [ ] **Verify :** `cargo test -p ailiance-brain --test api_agenda_http -- --nocapture`
139 +
140 +---
141 +
142 +## Task S4 — API catalogue ressources + CRUD réservations + GET indispos
143 +
144 +**Files :** `api.rs`, éventuellement petit module `handlers/api_ressources.rs` si `api.rs` trop gros
145 +**Test :** `api_agenda_http.rs`
146 +
147 +### Catalogue
148 +
149 +```http
150 +GET /api/ressources
151 +```
152 +
153 +Réponse :
154 +
155 +```json
156 +{
157 + "salles": [ { "id", "nom", "actif", … } ],
158 + "materiels": [ … ],
159 + "vehicules": [ … ]
160 +}
161 +```
162 +
163 +Uniquement ressources `actif == true` pour le mobile (ou toutes + flag `actif` — préférer **toutes** avec `actif` pour que Paramètres puisse griser).
164 +
165 +### Réservations
166 +
167 +```http
168 +GET /api/ressources/:genre/:id/reservations
169 +POST /api/ressources/:genre/:id/reservations
170 +PUT /api/ressources/:genre/:id/reservations/:rid
171 +DELETE /api/ressources/:genre/:id/reservations/:rid
172 +```
173 +
174 +`genre` ∈ `salle|materiel|vehicule` (pas `utilisateur` en v2 mobile).
175 +
176 +Body create :
177 +
178 +```json
179 +{
180 + "debut": "…",
181 + "fin": "…",
182 + "motif": "…",
183 + "projet_id": null,
184 + "rdv_id": null
185 +}
186 +```
187 +
188 +- `reserve_par` = Bearer user ; `statut` = **Active** forcé.
189 +- Appeler `ressources::creer_reservation` / `modifier_reservation`.
190 +- Sur `ErreurReservation::Conflit` → **HTTP 409** + JSON `{ "message": "…", "reclamable": bool }` (**ne pas** créer EnAttente depuis l’API mobile).
191 +
192 +### Indispos (lecture)
193 +
194 +```http
195 +GET /api/ressources/:genre/:id/indisponibilites
196 +```
197 +
198 +- [ ] Tests : create résa OK ; second chevauchant → 409 ; delete → 204.
199 +- [ ] OpenAPI.
200 +- [ ] **Verify :** mêmes tests `api_agenda_http`.
201 +
202 +---
203 +
204 +## Task S5 — Étendre `/api/sync/status` et `/api/sync/pull`
205 +
206 +**Files :** `services/sync.rs`, `handlers/api.rs`
207 +
208 +Query additionnelle (optionnelle) :
209 +
210 +```http
211 +GET /api/sync/pull?since=…&ressources=salle:abc,vehicule:xyz
212 +GET /api/sync/status?since=…&ressources=…
213 +```
214 +
215 +Parser `ressources` → `Vec<CibleRessource>` ; vide = **ne pas** renvoyer résas/indispos (client n’a lié aucun calendrier ressource) ; RDV toujours filtrés `utilisateur == Bearer`.
216 +
217 +Étendre `CompteursSync` / JSON `changes` :
218 +
219 +```json
220 +"changes": {
221 + "contacts": 0, "entreprises": 0, "projets": 0, "taches": 0, "interactions": 0,
222 + "rdv": 1, "reservations": 2, "indisponibilites": 1, "tombstones": 0
223 +}
224 +```
225 +
226 +`PullSync` :
227 +
228 +```json
229 +{
230 + "server_time": "…",
231 + "contacts": [], "entreprises": [], "projets": [], "taches": [], "interactions": [],
232 + "rdv": [ /* RendezVous */ ],
233 + "reservations": [ /* Reservation */ ],
234 + "indisponibilites": [ /* Indisponibilite */ ],
235 + "tombstones": [],
236 + "workflows": []
237 +}
238 +```
239 +
240 +Filtre `since` : `mis_a_jour_le.unwrap_or(cree_le) > since` (RDV/résa/indispo).
241 +
242 +Compat clients v1 : champs JSON **nouveaux avec défaut** `[]` / `0` — anciens parseurs Kotlin devront être mis à jour (Task M*). Serde côté serveur : toujours émettre les clés.
243 +
244 +- [ ] Tests : create RDV → status.rdv >= 1 pour owner ; autre user → 0 ; résa sur salle filtrée par query `ressources`.
245 +- [ ] **Verify :** `cargo test -p ailiance-brain --test api_agenda_http --test api_sync_http`
246 +
247 +---
248 +
249 +## Task S6 — Doc admin serveur
250 +
251 +**Files :** `Projectiaon/docs/guide-administrateur.md`, OpenAPI déjà S3–S5
252 +
253 +- [ ] Documenter `/api/rdv`, `/api/ressources`, sync `rdv`/`reservations`/`indisponibilites`, query `ressources=`, code 409.
254 +- [ ] Lien vers la spec Card2vcf v2.
255 +- [ ] **Verify :** relecture manuelle ; `cargo test --all-targets` vert sur Projectiaon.
256 +
257 +---
258 +
259 +# Partie B — Mobile
260 +
261 +## Task M1 — Permissions + `CalendarBindingsStore`
262 +
263 +**Files :**
264 +- Modify: `android/app/src/main/AndroidManifest.xml`
265 +- Create: `…/sync/CalendarBindingsStore.kt`
266 +- Test: `…/sync/CalendarBindingsStoreTest.kt`
267 +
268 +```kotlin
269 +@Serializable
270 +data class CalendarBinding(
271 + val kind: String, // "rdv" | "salle" | "materiel" | "vehicule"
272 + val serverResourceId: String? = null,
273 + val displayName: String,
274 + val androidCalendarId: Long? = null,
275 +)
276 +```
277 +
278 +- [ ] Permissions `READ_CALENDAR` / `WRITE_CALENDAR`.
279 +- [ ] Store JSON dans prefs (EncryptedSharedPreferences ou prefs dédiées sync — réutiliser pattern credentials).
280 +- [ ] `list()`, `upsert(binding)`, `remove(kind, serverResourceId?)`.
281 +- [ ] **Verify :** unit test round-trip.
282 +
283 +---
284 +
285 +## Task M2 — `CalendarBridge` (TDD sur mapping pur + smoke)
286 +
287 +**Files :**
288 +- Create: `…/sync/CalendarBridge.kt`
289 +- Create: `…/sync/CalendarEventSnapshot.kt` (data class titre, debutMs, finMs, eventId, serverIdExtra?)
290 +- Test: mapping helpers sans ContentResolver si possible ; Robolectric limité pour insert calendar
291 +
292 +API :
293 +
294 +```kotlin
295 +class CalendarBridge(private val resolver: ContentResolver) {
296 + fun ensureLocalCalendar(displayName: String): Long
297 + fun listEvents(calendarId: Long): List<CalendarEventSnapshot>
298 + fun upsertEvent(calendarId: Long, snap: CalendarEventSnapshot): Long
299 + fun deleteEvent(eventId: Long)
300 +}
301 +```
302 +
303 +- Calendriers **locaux** (`ACCOUNT_TYPE` local / `Calendars.ACCOUNT_NAME = "card2vcf"`) pour hors-GMS.
304 +- Stocker `serverId` dans `Events.DESCRIPTION` préfixe stable `card2vcf:serverId=…\n` **ou** extended properties si fiable ; documenter le choix dans le code.
305 +- Titre indispo : préfixe `[Indispo] `.
306 +
307 +- [ ] **Verify :** tests mapping parse/format serverId ; `assembleDebug` OK.
308 +
309 +---
310 +
311 +## Task M3 — Room v3 : RDV / Reservation / Indisponibilite
312 +
313 +**Files :** entities + DAOs ; `CrmDatabase` version **3** + `fallbackToDestructiveMigration` (doc README)
314 +
315 +Champs alignés spec §4.2 (`dirtyLocal`, `conflictPending`, `calendarEventId`, …).
316 +
317 +- [ ] DAOs : upsert, getByServerId, listDirty, deleteByServerId, listByCible.
318 +- [ ] Test smoke Robolectric `AgendaSchemaDaoTest`.
319 +- [ ] **Verify :** `./gradlew :app:testDebugUnitTest --tests '*AgendaSchema*'` + tests data existants.
320 +
321 +---
322 +
323 +## Task M4 — DTOs + `AilianceApi` méthodes agenda
324 +
325 +**Files :** `SyncModels.kt`, `AilianceApi.kt`, `AilianceApiClient.kt`
326 +**Test :** MockWebServer
327 +
328 +- DTOs `RendezVousDto`, `ReservationDto`, `IndisponibiliteDto`, `RessourcesCatalogueDto`.
329 +- Étendre `SyncStatusResponse.changes` + `SyncPullResponse` avec `rdv`, `reservations`, `indisponibilites` (défaut empty).
330 +- `syncStatus(since, ressourcesQuery: String?)`, `syncPull(since, ressourcesQuery: String?)`.
331 +- CRUD rdv + résa ; `GET /api/ressources`.
332 +- Parser 409 → `ApiResult.Err(409, message)`.
333 +
334 +- [ ] **Verify :** tests client verts ; régessions `AilianceApiClientTest`.
335 +
336 +---
337 +
338 +## Task M5 — SyncEngine : RDV LWW + résas + Agenda↔Room
339 +
340 +**Files :** `SyncEngine.kt` (+ collaborator `AgendaSyncCoordinator` si fichier trop gros)
341 +
342 +Étendre `syncNow(bindings: List<CalendarBinding>, bridge: CalendarBridge)` :
343 +
344 +1. `agendaToRoom(bindings)` → dirty → SyncOp `rdv`/`reservation`
345 +2. `pushOps()` — sur résa create/update si `Err.code == 409` : set `conflictPending`, **ne pas** drop SyncOp tout de suite ; retourner `SyncResult(conflicts = […])`
346 +3. `pull` avec query `ressources=` dérivée des bindings
347 +4. apply LWW RDV ; résas Active ; indispos append/replace by serverId
348 +5. `roomToAgenda(bindings)`
349 +6. watermark
350 +
351 +`localAheadCount()` : dirty Room + SyncOp pending rdv/reservation (+ events orphelins).
352 +
353 +- [ ] Tests Fake API + in-memory Room : LWW RDV ; 409 set conflictPending ; cancel path helper `abandonLocalReservation(localId)`.
354 +- [ ] **Verify :** `SyncEngineTest` + nouveaux tests.
355 +
356 +---
357 +
358 +## Task M6 — UI Paramètres calendriers
359 +
360 +**Files :** `SettingsScreen.kt`, `SettingsViewModel.kt`, strings FR/EN
361 +
362 +- Demande runtime permissions calendrier.
363 +- Toggle Mes RDV → `ensureLocalCalendar("Card2vcf — Mes RDV")` + binding.
364 +- Fetch catalogue ressources si credentials → toggles par ressource active.
365 +- Confirmation avant remove + delete calendar optionnel.
366 +
367 +- [ ] **Verify :** `assembleDebug` ; test ViewModel permissions/bindings mock si possible.
368 +
369 +---
370 +
371 +## Task M7 — Bandeaux + dialog conflit
372 +
373 +**Files :** `SyncBanner.kt`, `SyncChromeViewModel.kt`, strings
374 +
375 +- Bandeau A : changements serveur (`pendingRemoteChanges`) — existant.
376 +- Bandeau B : `localCalendarAhead > 0` → « Calendrier local en avance sur le serveur ».
377 +- Après `syncNow`, si `conflicts` non vide → dialog « Annuler ma réservation ? » (une par une ou première).
378 + - Oui → `abandonLocalReservation` + delete event Agenda.
379 + - Non → dismiss ; SyncOp peut rester / flag `conflictPending` (manager serveur).
380 +
381 +- [ ] **Verify :** test ViewModel conflit ; compile.
382 +
383 +---
384 +
385 +## Task M8 — Doc README Card2vcf
386 +
387 +**Files :** `EBII_mobileVCF/README.md`
388 +
389 +- Section Agenda / calendriers : permissions, Paramètres, pas d’écran agenda, conflit 409, hors-GMS calendriers locaux.
390 +- Lien spec + ce plan.
391 +- Nuancer destructive migration Room v3.
392 +
393 +- [ ] **Verify :** relecture ; `./gradlew :app:testDebugUnitTest assembleDebug`.
394 +
395 +---
396 +
397 +## Ordre / commits suggérés (si demandé)
398 +
399 +1. `feat(api): mis_a_jour_le rdv reservation indispo`
400 +2. `feat(sync): tombstones agenda types`
401 +3. `feat(api): CRUD /api/rdv`
402 +4. `feat(api): ressources catalog + reservations 409`
403 +5. `feat(api): sync pull rdv reservations indispos`
404 +6. `docs(admin): agenda mobile API`
405 +7. `feat(android): calendar bindings + permissions`
406 +8. `feat(android): CalendarBridge local calendars`
407 +9. `feat(android): Room v3 agenda entities`
408 +10. `feat(android): API DTOs agenda`
409 +11. `feat(android): SyncEngine agenda bridge`
410 +12. `feat(ui): settings calendars + conflict dialog`
411 +13. `docs: README agenda sync`
412 +
413 +## Critère de done v2
414 +
415 +- Curl Bearer : CRUD RDV + résa ; 409 chevauchement ; sync pull filtre `ressources=` + mes RDV.
416 +- App : lier Mes RDV + une salle → events Agenda ↔ serveur après Sync.
417 +- Conflit : deux events visibles + dialog annulation.
418 +- Bandeau local en avance.
419 +- `cargo test --all-targets` (Projectiaon) et `./gradlew :app:testDebugUnitTest assembleDebug` verts.
420 +- Pas d’écran agenda dans la nav.
421 +
422 +## Coverage spec (self-check)
423 +
424 +| Spec § | Tasks |
425 +|---|---|
426 +| RDV LWW + mes RDV | S1, S3, S5, M3–M5 |
427 +| Multi-calendriers Paramètres | M1, M6 |
428 +| Event ressource → résa Active | S4, M5 |
429 +| Indispos lecture | S4, S5, M3, M5 |
430 +| 409 → refresh + annuler | S4, M5, M7 |
431 +| Bandeau local en avance | M5, M7 |
432 +| Pas UI agenda / pas demandes mobile | M6–M8 (hors scope respecté) |
433 +| Doc | S6, M8 |
M docs/plans/sync_card2vcf_projectiaon_v1.md
+3 -2
@@ -336,6 +336,7 @@ Séquence bouton Sync :
336 336 - `./gradlew :app:testDebugUnitTest` vert ; `assembleDebug` OK
337 337 - Pas de clé/mdp en clair dans logs
338 338
339 -## v2 (plus tard, plan séparé)
339 +## v2 (plan séparé)
340 340
341 -API RDV/ressources/réservations + Agenda Android (`CalendarContract`).
341 +Spec design : [`docs/superpowers/specs/2026-07-22-sync-agenda-rdv-ressources-v2-design.md`](../superpowers/specs/2026-07-22-sync-agenda-rdv-ressources-v2-design.md)
342 +(RDV + calendriers ressources Agenda Android, pas d’écran agenda in-app.)