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.) |
GitRust