synchro ok + vcard

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

25500a165206fcf3fe97ea0b9aea69d92d608bbc

1 parent(s)

21 fichiers modifiés +704 -25
M android/app/src/main/AndroidManifest.xml
+1 -0
@@ -11,6 +11,7 @@
11 11 android:label="@string/app_name"
12 12 android:icon="@mipmap/ic_launcher"
13 13 android:roundIcon="@mipmap/ic_launcher_round"
14 + android:networkSecurityConfig="@xml/network_security_config"
14 15 android:theme="@android:style/Theme.Material.Light.NoActionBar">
15 16 <provider
16 17 android:name="androidx.core.content.FileProvider"
M android/app/src/main/java/fr/ebii/card2vcf/data/ContactImageStore.kt
+7 -0
@@ -16,6 +16,13 @@ class ContactImageStore(private val context: Context) {
16 16 return file.absolutePath
17 17 }
18 18
19 + /** Écrit des octets bruts (ex. image téléchargée depuis le serveur). */
20 + fun saveBytes(contactId: Long, name: String, bytes: ByteArray): String {
21 + val file = File(dir(contactId), name)
22 + file.writeBytes(bytes)
23 + return file.absolutePath
24 + }
25 +
19 26 fun deleteAll(contactId: Long) {
20 27 dir(contactId).deleteRecursively()
21 28 }
M android/app/src/main/java/fr/ebii/card2vcf/data/ContactRepository.kt
+1 -0
@@ -116,6 +116,7 @@ class ContactRepository(
116 116 updatedAt = System.currentTimeMillis(),
117 117 ),
118 118 )
119 + dao.getById(id)?.let { enqueueContactUpdate(it) }
119 120 }
120 121
121 122 suspend fun markDifferent(a: Long, b: Long) {
M android/app/src/main/java/fr/ebii/card2vcf/sync/AilianceApi.kt
+18 -0
@@ -62,4 +62,22 @@ interface AilianceApi {
62 62 fun deleteReservation(genre: String, resourceId: String, reservationId: String): AilianceApiClient.ApiResult<Unit>
63 63
64 64 fun listIndisponibilites(genre: String, resourceId: String): AilianceApiClient.ApiResult<List<IndisponibiliteDto>>
65 +
66 + fun uploadContactCarte(
67 + id: String,
68 + bytes: ByteArray,
69 + filename: String,
70 + contentType: String = "image/jpeg",
71 + ): AilianceApiClient.ApiResult<String>
72 +
73 + fun uploadContactPhoto(
74 + id: String,
75 + bytes: ByteArray,
76 + filename: String,
77 + contentType: String = "image/jpeg",
78 + ): AilianceApiClient.ApiResult<String>
79 +
80 + fun downloadContactCarte(id: String): AilianceApiClient.ApiResult<ByteArray>
81 +
82 + fun downloadContactPhoto(id: String): AilianceApiClient.ApiResult<ByteArray>
65 83 }
M android/app/src/main/java/fr/ebii/card2vcf/sync/AilianceApiClient.kt
+109 -5
@@ -1,16 +1,21 @@
1 1 package fr.ebii.card2vcf.sync
2 2
3 +import android.util.Log
3 4 import kotlinx.serialization.builtins.ListSerializer
5 +import okhttp3.Interceptor
4 6 import okhttp3.MediaType.Companion.toMediaType
7 +import okhttp3.MultipartBody
5 8 import okhttp3.OkHttpClient
6 9 import okhttp3.Request
7 10 import okhttp3.RequestBody.Companion.toRequestBody
8 11 import java.io.IOException
12 +import java.net.Proxy
13 +import java.util.concurrent.TimeUnit
9 14
10 15 class AilianceApiClient(
11 16 baseUrl: String,
12 17 private val apiKey: String? = null,
13 - private val client: OkHttpClient = OkHttpClient(),
18 + private val client: OkHttpClient = defaultHttpClient(),
14 19 ) : AilianceApi {
15 20 private val base = baseUrl.trimEnd('/')
16 21 private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
@@ -124,6 +129,67 @@ class AilianceApiClient(
124 129 syncJson.decodeFromString(ListSerializer(IndisponibiliteDto.serializer()), body)
125 130 }
126 131
132 + override fun uploadContactCarte(
133 + id: String,
134 + bytes: ByteArray,
135 + filename: String,
136 + contentType: String,
137 + ): ApiResult<String> =
138 + uploadMultipart("/api/contacts/${encodePath(id)}/carte", bytes, filename, contentType)
139 +
140 + override fun uploadContactPhoto(
141 + id: String,
142 + bytes: ByteArray,
143 + filename: String,
144 + contentType: String,
145 + ): ApiResult<String> =
146 + uploadMultipart("/api/contacts/${encodePath(id)}/photo", bytes, filename, contentType)
147 +
148 + override fun downloadContactCarte(id: String): ApiResult<ByteArray> =
149 + executeBytes(buildRequest("GET", "/api/contacts/${encodePath(id)}/carte", null, useBearer = true))
150 +
151 + override fun downloadContactPhoto(id: String): ApiResult<ByteArray> =
152 + executeBytes(buildRequest("GET", "/api/contacts/${encodePath(id)}/photo", null, useBearer = true))
153 +
154 + private fun uploadMultipart(
155 + path: String,
156 + bytes: ByteArray,
157 + filename: String,
158 + contentType: String,
159 + ): ApiResult<String> {
160 + val part = MultipartBody.Builder()
161 + .setType(MultipartBody.FORM)
162 + .addFormDataPart(
163 + "file",
164 + filename,
165 + bytes.toRequestBody(contentType.toMediaType()),
166 + )
167 + .build()
168 + val builder = Request.Builder().url("$base$path").post(part)
169 + if (!apiKey.isNullOrBlank()) {
170 + builder.header("Authorization", "Bearer $apiKey")
171 + }
172 + return execute(builder.build()) { it }
173 + }
174 +
175 + private fun executeBytes(request: Request): ApiResult<ByteArray> {
176 + return try {
177 + client.newCall(request).execute().use { response ->
178 + val body = response.body?.bytes() ?: ByteArray(0)
179 + if (response.isSuccessful) {
180 + ApiResult.Ok(body)
181 + } else {
182 + val text = body.toString(Charsets.UTF_8)
183 + ApiResult.Err(response.code, errorMessage(text, response.message))
184 + }
185 + }
186 + } catch (e: IOException) {
187 + ApiResult.Err(-1, e.message ?: "Erreur réseau")
188 + } catch (e: Exception) {
189 + ApiResult.Err(-1, e.message ?: "Erreur inattendue")
190 + }
191 + }
192 +
127 193 private inline fun <T> get(path: String, crossinline parse: (String) -> T): ApiResult<T> =
128 194 execute(buildRequest("GET", path, null, useBearer = true), parse)
129 195
@@ -182,7 +248,16 @@ class AilianceApiClient(
182 248 }
183 249 }
184 250 } catch (e: IOException) {
185 - ApiResult.Err(-1, e.message ?: "Erreur réseau")
251 + val detail = e.message ?: "connexion impossible"
252 + val hint =
253 + if (detail.contains("refused", ignoreCase = true) ||
254 + detail.contains("ECONNREFUSED", ignoreCase = true)
255 + ) {
256 + " (paquet non reçu par le serveur : vérifiez IP Wi‑Fi du PC, pas de proxy téléphone, même Wi‑Fi)"
257 + } else {
258 + ""
259 + }
260 + ApiResult.Err(-1, "$detail → ${request.method} ${request.url}$hint")
186 261 } catch (e: Exception) {
187 262 ApiResult.Err(-1, e.message ?: "Erreur inattendue")
188 263 }
@@ -207,11 +282,40 @@ class AilianceApiClient(
207 282 private fun ressourcesQueryParam(ressourcesQuery: String?): String =
208 283 if (ressourcesQuery.isNullOrBlank()) "" else "&ressources=${encodeQuery(ressourcesQuery)}"
209 284
210 - private companion object {
211 - fun encodeQuery(value: String): String =
285 + companion object {
286 + const val HTTP_LOG_TAG = "Card2vcfHttp"
287 +
288 + /**
289 + * Pas de proxy système : sinon OkHttp peut tenter un proxy Wi‑Fi/VPN qui refuse
290 + * le LAN (`ECONNREFUSED`) alors que le navigateur (souvent hors proxy) fonctionne.
291 + * Interceptor : traces `logcat -s Card2vcfHttp` pour debug téléphone.
292 + */
293 + fun defaultHttpClient(): OkHttpClient =
294 + OkHttpClient.Builder()
295 + .proxy(Proxy.NO_PROXY)
296 + .connectTimeout(15, TimeUnit.SECONDS)
297 + .readTimeout(30, TimeUnit.SECONDS)
298 + .writeTimeout(30, TimeUnit.SECONDS)
299 + .addInterceptor(httpTraceInterceptor())
300 + .build()
301 +
302 + private fun httpTraceInterceptor(): Interceptor = Interceptor { chain ->
303 + val request = chain.request()
304 + Log.i(HTTP_LOG_TAG, "→ ${request.method} ${request.url}")
305 + try {
306 + val response = chain.proceed(request)
307 + Log.i(HTTP_LOG_TAG, "← ${response.code} ${request.url}")
308 + response
309 + } catch (e: Exception) {
310 + Log.e(HTTP_LOG_TAG, "✗ ${request.method} ${request.url}: ${e.javaClass.simpleName}: ${e.message}", e)
311 + throw e
312 + }
313 + }
314 +
315 + private fun encodeQuery(value: String): String =
212 316 java.net.URLEncoder.encode(value, Charsets.UTF_8)
213 317
214 - fun encodePath(segment: String): String =
318 + private fun encodePath(segment: String): String =
215 319 segment.split("/").joinToString("/") { part ->
216 320 java.net.URLEncoder.encode(part, Charsets.UTF_8)
217 321 }
A android/app/src/main/java/fr/ebii/card2vcf/sync/ServerUrlPolicy.kt
+59 -0
@@ -0,0 +1,59 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +/**
4 + * Politique d'URL serveur : HTTPS obligatoire par défaut.
5 + * Le HTTP clair n'est accepté que si l'utilisateur a coché l'option
6 + * « Autoriser HTTP (clair) » (dev / réseau interne / démo).
7 + */
8 +object ServerUrlPolicy {
9 + const val ERROR_HTTPS_REQUIRED =
10 + "L'URL doit commencer par https:// (cochez « Autoriser HTTP (clair) » pour le développement)."
11 + const val ERROR_INVALID_URL =
12 + "URL invalide : utilisez https://… (ou http://… si HTTP clair autorisé)."
13 + const val ERROR_LAN_HTTPS_NO_PORT =
14 + "URL HTTPS sans port sur une IP locale : Projectiaon écoute en HTTP sur le port 3000. " +
15 + "Utilisez http://192.168.x.x:3000 et cochez « Autoriser HTTP (clair) »."
16 + const val ERROR_LAN_HTTP_NO_PORT =
17 + "URL sans port : Projectiaon écoute sur le port 3000 (ex. http://192.168.x.x:3000)."
18 +
19 + fun isHttps(url: String): Boolean = url.trim().startsWith("https://", ignoreCase = true)
20 +
21 + fun isHttp(url: String): Boolean = url.trim().startsWith("http://", ignoreCase = true)
22 +
23 + /** Racine API : sans slash final ni chemin de test navigateur (`/version`). */
24 + fun normalizeBaseUrl(url: String): String =
25 + url.trim().trimEnd('/').removeSuffix("/version")
26 +
27 + /** `null` si l'URL est acceptable, sinon message d'erreur à afficher. */
28 + fun validate(url: String, allowCleartext: Boolean): String? {
29 + val trimmed = normalizeBaseUrl(url)
30 + if (trimmed.isEmpty()) return ERROR_INVALID_URL
31 + return when {
32 + isHttps(trimmed) -> lanHostWithoutPortError(trimmed)
33 + isHttp(trimmed) -> when {
34 + !allowCleartext -> ERROR_HTTPS_REQUIRED
35 + else -> lanHostWithoutPortError(trimmed)
36 + }
37 + else -> ERROR_INVALID_URL
38 + }
39 + }
40 +
41 + private fun lanHostWithoutPortError(url: String): String? {
42 + val uri = runCatching { java.net.URI(url) }.getOrNull() ?: return null
43 + if (uri.port != -1) return null
44 + val host = uri.host ?: return null
45 + if (!isPrivateLanHost(host)) return null
46 + return when {
47 + isHttps(url) -> ERROR_LAN_HTTPS_NO_PORT
48 + isHttp(url) -> ERROR_LAN_HTTP_NO_PORT
49 + else -> null
50 + }
51 + }
52 +
53 + private fun isPrivateLanHost(host: String): Boolean {
54 + if (host.equals("localhost", ignoreCase = true) || host == "127.0.0.1") return true
55 + if (host.startsWith("192.168.")) return true
56 + if (host.startsWith("10.")) return true
57 + return host.matches(Regex("""172\.(1[6-9]|2\d|3[01])\..+"""))
58 + }
59 +}
M android/app/src/main/java/fr/ebii/card2vcf/sync/SyncCredentialsStore.kt
+18 -1
@@ -34,6 +34,17 @@ class SyncCredentialsStore internal constructor(
34 34 }.apply()
35 35 }
36 36
37 + /**
38 + * Autorise les URL `http://` (réseau interne / démo / dev).
39 + * Conservé après [clear] : ce n'est pas un secret, juste une préférence locale.
40 + * Défaut : `false` (HTTPS obligatoire).
41 + */
42 + var allowCleartextHttp: Boolean
43 + get() = prefs.getBoolean(KEY_ALLOW_CLEARTEXT, false)
44 + set(value) {
45 + prefs.edit().putBoolean(KEY_ALLOW_CLEARTEXT, value).apply()
46 + }
47 +
37 48 fun save(baseUrl: String, apiKey: String, userName: String) {
38 49 prefs.edit()
39 50 .putString(KEY_BASE_URL, baseUrl)
@@ -43,7 +54,12 @@ class SyncCredentialsStore internal constructor(
43 54 }
44 55
45 56 fun clear() {
46 - prefs.edit().clear().apply()
57 + // Ne pas effacer [allowCleartextHttp] : pratique en démo/interne entre deux connexions.
58 + prefs.edit()
59 + .remove(KEY_BASE_URL)
60 + .remove(KEY_API_KEY)
61 + .remove(KEY_USER_NAME)
62 + .apply()
47 63 }
48 64
49 65 fun isConfigured(): Boolean =
@@ -54,6 +70,7 @@ class SyncCredentialsStore internal constructor(
54 70 const val KEY_BASE_URL = "base_url"
55 71 const val KEY_API_KEY = "api_key"
56 72 const val KEY_USER_NAME = "user_name"
73 + const val KEY_ALLOW_CLEARTEXT = "allow_cleartext_http"
57 74
58 75 internal fun createEncryptedPrefs(context: Context): SharedPreferences =
59 76 EncryptedSharedPreferences.create(
M android/app/src/main/java/fr/ebii/card2vcf/sync/SyncEngine.kt
+134 -13
@@ -34,6 +34,7 @@ data class SyncResult(
34 34 class SyncEngine(
35 35 private val api: AilianceApi,
36 36 private val db: CrmDatabase,
37 + private val imageStore: fr.ebii.card2vcf.data.ContactImageStore? = null,
37 38 ) {
38 39 private val agenda = AgendaSyncCoordinator(db)
39 40
@@ -115,6 +116,7 @@ class SyncEngine(
115 116 for (op in db.syncOpDao().listAll()) {
116 117 val outcome = when (op.entityType) {
117 118 "contact" -> PushOutcome(pushContactOp(op))
119 + ENTITY_CONTACT_MEDIA -> PushOutcome(pushContactMediaOp(op))
118 120 "entreprise" -> PushOutcome(pushEntrepriseOp(op))
119 121 "projet" -> PushOutcome(pushProjetOp(op))
120 122 "tache" -> PushOutcome(pushTacheOp(op))
@@ -129,22 +131,84 @@ class SyncEngine(
129 131 return conflicts
130 132 }
131 133
132 - private suspend fun pushContactOp(op: SyncOpEntity): Boolean = when (op.op) {
133 - "create" -> {
134 - val result = api.createContact(op.payloadJson)
135 - if (result is AilianceApiClient.ApiResult.Ok) {
136 - val newServerId = parseCreatedId(result.value)
137 - if (newServerId != null && op.localId != null) {
138 - db.crmContactDao().getById(op.localId)?.let {
139 - db.crmContactDao().update(it.copy(serverId = newServerId))
134 + private suspend fun pushContactOp(op: SyncOpEntity): Boolean {
135 + return when (op.op) {
136 + "create" -> {
137 + val result = api.createContact(op.payloadJson)
138 + if (result is AilianceApiClient.ApiResult.Ok) {
139 + val newServerId = parseCreatedId(result.value)
140 + if (newServerId != null && op.localId != null) {
141 + db.crmContactDao().getById(op.localId)?.let {
142 + db.crmContactDao().update(it.copy(serverId = newServerId))
143 + }
144 + if (!pushContactImages(op.localId, newServerId)) {
145 + enqueueContactMediaRetry(op.localId, newServerId)
146 + }
140 147 }
141 148 }
149 + result is AilianceApiClient.ApiResult.Ok
150 + }
151 + "update" -> {
152 + val serverId = op.serverId ?: return false
153 + if (!api.updateContact(serverId, op.payloadJson).isOk()) return false
154 + val localId = op.localId ?: db.crmContactDao().getByServerId(serverId)?.id
155 + if (localId != null && !pushContactImages(localId, serverId)) {
156 + enqueueContactMediaRetry(localId, serverId)
157 + }
158 + true
142 159 }
143 - result is AilianceApiClient.ApiResult.Ok
160 + "delete" -> op.serverId?.let { api.deleteContact(it).isOk() } ?: false
161 + else -> true
144 162 }
145 - "update" -> op.serverId?.let { api.updateContact(it, op.payloadJson).isOk() } ?: false
146 - "delete" -> op.serverId?.let { api.deleteContact(it).isOk() } ?: false
147 - else -> true
163 + }
164 +
165 + private suspend fun pushContactMediaOp(op: SyncOpEntity): Boolean {
166 + val serverId = op.serverId ?: return true
167 + val localId = op.localId ?: db.crmContactDao().getByServerId(serverId)?.id ?: return true
168 + return pushContactImages(localId, serverId)
169 + }
170 +
171 + /**
172 + * Upload carte / photo locales vers le serveur. `true` si rien à envoyer ou tout OK.
173 + * `false` si au moins un fichier présent n'a pas pu être téléversé (retry via [ENTITY_CONTACT_MEDIA]).
174 + */
175 + private suspend fun pushContactImages(localId: Long, serverId: String): Boolean {
176 + val contact = db.crmContactDao().getById(localId) ?: return true
177 + var ok = true
178 + val cardPath = contact.cardImagePath?.takeIf { it.isNotBlank() }
179 + if (cardPath != null) {
180 + val file = java.io.File(cardPath)
181 + if (file.isFile) {
182 + val result = api.uploadContactCarte(serverId, file.readBytes(), "card.jpg")
183 + if (result !is AilianceApiClient.ApiResult.Ok) ok = false
184 + }
185 + }
186 + val profilePath = contact.profileImagePath?.takeIf { it.isNotBlank() }
187 + if (profilePath != null) {
188 + val file = java.io.File(profilePath)
189 + if (file.isFile) {
190 + val result = api.uploadContactPhoto(serverId, file.readBytes(), "profile.jpg")
191 + if (result !is AilianceApiClient.ApiResult.Ok) ok = false
192 + }
193 + }
194 + return ok
195 + }
196 +
197 + private suspend fun enqueueContactMediaRetry(localId: Long, serverId: String) {
198 + val already = db.syncOpDao().listAll().any {
199 + it.entityType == ENTITY_CONTACT_MEDIA && it.localId == localId && it.serverId == serverId
200 + }
201 + if (already) return
202 + db.syncOpDao().insert(
203 + SyncOpEntity(
204 + entityType = ENTITY_CONTACT_MEDIA,
205 + op = "upload",
206 + payloadJson = "{}",
207 + localId = localId,
208 + serverId = serverId,
209 + createdAt = System.currentTimeMillis(),
210 + ),
211 + )
148 212 }
149 213
150 214 private suspend fun pushEntrepriseOp(op: SyncOpEntity): Boolean = when (op.op) {
@@ -314,7 +378,7 @@ class SyncEngine(
314 378 val remoteTs = parseIsoToEpochMs(dto.misAJourLe ?: dto.creeLe)
315 379 val local = db.crmContactDao().getByServerId(dto.id)
316 380 if (local == null) {
317 - db.crmContactDao().insert(
381 + val newId = db.crmContactDao().insert(
318 382 CrmContactEntity(
319 383 serverId = dto.id,
320 384 fullName = "${dto.prenom} ${dto.nom}".trim(),
@@ -329,6 +393,7 @@ class SyncEngine(
329 393 updatedAt = remoteTs,
330 394 ),
331 395 )
396 + pullContactImages(newId, dto, remoteTs, localUpdatedAt = 0L)
332 397 return
333 398 }
334 399 val remoteEntity = local.copy(
@@ -344,8 +409,63 @@ class SyncEngine(
344 409 )
345 410 val merged = LwwMerger.pickLww(local, local.updatedAt, remoteEntity, remoteTs)
346 411 if (merged !== local) db.crmContactDao().update(merged)
412 + pullContactImages(local.id, dto, remoteTs, local.updatedAt)
347 413 }
348 414
415 + /**
416 + * Télécharge carte/photo si présentes côté serveur et (fichier local absent
417 + * ou serveur plus récent), sauf si un push contact/média est encore en file.
418 + */
419 + private suspend fun pullContactImages(
420 + localId: Long,
421 + dto: ContactDto,
422 + remoteTs: Long,
423 + localUpdatedAt: Long,
424 + ) {
425 + val store = imageStore ?: return
426 + if (hasPendingContactPush(localId, dto.id)) return
427 + var contact = db.crmContactDao().getById(localId) ?: return
428 + val serverNewer = remoteTs > localUpdatedAt
429 +
430 + if (!dto.carteVisite.isNullOrBlank()) {
431 + val path = contact.cardImagePath
432 + val missing = path.isNullOrBlank() || !java.io.File(path).isFile
433 + if (missing || serverNewer) {
434 + when (val result = api.downloadContactCarte(dto.id)) {
435 + is AilianceApiClient.ApiResult.Ok -> {
436 + val saved = store.saveBytes(localId, "card.jpg", result.value)
437 + contact = contact.copy(cardImagePath = saved)
438 + db.crmContactDao().update(contact)
439 + }
440 + is AilianceApiClient.ApiResult.Err -> Unit
441 + }
442 + }
443 + }
444 + if (!dto.photo.isNullOrBlank()) {
445 + val path = contact.profileImagePath
446 + val missing = path.isNullOrBlank() || !java.io.File(path).isFile
447 + if (missing || serverNewer) {
448 + when (val result = api.downloadContactPhoto(dto.id)) {
449 + is AilianceApiClient.ApiResult.Ok -> {
450 + val saved = store.saveBytes(localId, "profile.jpg", result.value)
451 + contact = contact.copy(profileImagePath = saved)
452 + db.crmContactDao().update(contact)
453 + }
454 + is AilianceApiClient.ApiResult.Err -> Unit
455 + }
456 + }
457 + }
458 + }
459 +
460 + private suspend fun hasPendingContactPush(localId: Long, serverId: String): Boolean =
461 + db.syncOpDao().listAll().any { op ->
462 + when (op.entityType) {
463 + "contact", ENTITY_CONTACT_MEDIA ->
464 + (op.localId == localId || op.serverId == serverId) && op.op != "delete"
465 + else -> false
466 + }
467 + }
468 +
349 469 private suspend fun applyEntreprise(dto: EntrepriseDto) {
350 470 val remoteTs = parseIsoToEpochMs(dto.misAJourLe ?: dto.creeLe)
351 471 val local = db.entrepriseDao().getByServerId(dto.id)
@@ -483,6 +603,7 @@ class SyncEngine(
483 603 private companion object {
484 604 const val WATERMARK_KEY = "watermark"
485 605 const val DEFAULT_WATERMARK = "1970-01-01T00:00:00Z"
606 + const val ENTITY_CONTACT_MEDIA = "contact_media"
486 607
487 608 fun <T> AilianceApiClient.ApiResult<T>.isOk(): Boolean = this is AilianceApiClient.ApiResult.Ok
488 609
M android/app/src/main/java/fr/ebii/card2vcf/sync/SyncModels.kt
+4 -0
@@ -94,6 +94,10 @@ data class ContactDto(
94 94 val creePar: String = "",
95 95 val creeLe: String,
96 96 val misAJourLe: String? = null,
97 + /** Nom de fichier serveur (`carte.jpg`), pas les octets. */
98 + val carteVisite: String? = null,
99 + /** Nom de fichier serveur (`photo.jpg`), pas les octets. */
100 + val photo: String? = null,
97 101 )
98 102
99 103 @Serializable
M android/app/src/main/java/fr/ebii/card2vcf/ui/nav/Card2vcfNavHost.kt
+5 -1
@@ -110,7 +110,11 @@ fun Card2vcfNavHost(
110 110 val baseUrl = credentialsStore.baseUrl
111 111 val apiKey = credentialsStore.apiKey
112 112 if (baseUrl != null && apiKey != null) {
113 - SyncEngine(AilianceApiClient(baseUrl, apiKey), database)
113 + SyncEngine(
114 + AilianceApiClient(baseUrl, apiKey),
115 + database,
116 + fr.ebii.card2vcf.data.ContactImageStore(context.applicationContext),
117 + )
114 118 } else {
115 119 null
116 120 }
M android/app/src/main/java/fr/ebii/card2vcf/ui/settings/SettingsScreen.kt
+11 -0
@@ -309,6 +309,17 @@ private fun LoggedOutContent(
309 309 placeholder = { Text(stringResource(R.string.settings_mot_de_passe_placeholder), color = TexteFaible) },
310 310 colors = fieldColors,
311 311 )
312 + ToggleRow(
313 + label = stringResource(R.string.settings_allow_cleartext),
314 + checked = state.allowCleartextHttp,
315 + enabled = true,
316 + onCheckedChange = viewModel::setAllowCleartextHttp,
317 + )
318 + Text(
319 + stringResource(R.string.settings_allow_cleartext_hint),
320 + color = TexteFaible,
321 + style = MaterialTheme.typography.bodySmall,
322 + )
312 323 if (state.error != null) {
313 324 Text(state.error, color = Ink)
314 325 }
M android/app/src/main/java/fr/ebii/card2vcf/ui/settings/SettingsViewModel.kt
+23 -5
@@ -13,6 +13,7 @@ import fr.ebii.card2vcf.sync.CalendarBindingsStore
13 13 import fr.ebii.card2vcf.sync.CalendarBridgeApi
14 14 import fr.ebii.card2vcf.sync.RessourceItemDto
15 15 import fr.ebii.card2vcf.sync.RessourcesCatalogueDto
16 +import fr.ebii.card2vcf.sync.ServerUrlPolicy
16 17 import fr.ebii.card2vcf.sync.SyncCredentialsStore
17 18 import kotlinx.coroutines.CoroutineDispatcher
18 19 import kotlinx.coroutines.Dispatchers
@@ -56,6 +57,8 @@ sealed interface SettingsUiState {
56 57 val baseUrl: String = "",
57 58 val userName: String = "",
58 59 val password: String = "",
60 + /** Si true, accepte aussi `http://` (dev / interne / démo). Défaut false = HTTPS only. */
61 + val allowCleartextHttp: Boolean = false,
59 62 val loading: Boolean = false,
60 63 val error: String? = null,
61 64 val pendingAuth: AuthCleResponse? = null,
@@ -92,7 +95,10 @@ class SettingsViewModel(
92 95 mesRdvLinked = calendarBindingsStore.list().any { it.kind == AgendaSyncCoordinator.KIND_RDV },
93 96 )
94 97 } else {
95 - SettingsUiState.LoggedOut(baseUrl = baseUrl.orEmpty())
98 + SettingsUiState.LoggedOut(
99 + baseUrl = baseUrl.orEmpty(),
100 + allowCleartextHttp = credentialsStore.allowCleartextHttp,
101 + )
96 102 }
97 103 }
98 104
@@ -104,15 +110,24 @@ class SettingsViewModel(
104 110
105 111 fun setRetypePassword(value: String) = updateLoggedOut { it.copy(retypePassword = value, retypeError = null) }
106 112
113 + fun setAllowCleartextHttp(value: Boolean) {
114 + credentialsStore.allowCleartextHttp = value
115 + updateLoggedOut { it.copy(allowCleartextHttp = value, error = null) }
116 + }
117 +
107 118 fun obtainKey() {
108 119 val s = _state.value
109 120 if (s !is SettingsUiState.LoggedOut) return
110 - val baseUrl = s.baseUrl.trim()
121 + val baseUrl = ServerUrlPolicy.normalizeBaseUrl(s.baseUrl)
111 122 val userName = s.userName.trim()
112 123 if (baseUrl.isBlank() || userName.isBlank() || s.password.isBlank()) {
113 124 _state.value = s.copy(error = "Renseignez l'URL, le nom d'utilisateur et le mot de passe")
114 125 return
115 126 }
127 + ServerUrlPolicy.validate(baseUrl, s.allowCleartextHttp)?.let { err ->
128 + _state.value = s.copy(error = err)
129 + return
130 + }
116 131 viewModelScope.launch {
117 132 _state.value = s.copy(loading = true, error = null)
118 133 val result = withContext(ioDispatcher) {
@@ -150,10 +165,11 @@ class SettingsViewModel(
150 165 }
151 166 try {
152 167 val apiKey = ApiCleCrypto.decryptApiKey(s.retypePassword, auth.sel, auth.cleChiffree)
153 - credentialsStore.save(baseUrl = s.baseUrl.trim(), apiKey = apiKey, userName = auth.nom)
168 + val baseUrl = ServerUrlPolicy.normalizeBaseUrl(s.baseUrl)
169 + credentialsStore.save(baseUrl = baseUrl, apiKey = apiKey, userName = auth.nom)
154 170 _state.value = SettingsUiState.LoggedIn(
155 171 userName = auth.nom,
156 - baseUrl = s.baseUrl.trim(),
172 + baseUrl = baseUrl,
157 173 mesRdvLinked = calendarBindingsStore.list().any { it.kind == AgendaSyncCoordinator.KIND_RDV },
158 174 )
159 175 } catch (e: ApiCleCryptoException) {
@@ -165,7 +181,9 @@ class SettingsViewModel(
165 181
166 182 fun disconnect() {
167 183 credentialsStore.clear()
168 - _state.value = SettingsUiState.LoggedOut()
184 + _state.value = SettingsUiState.LoggedOut(
185 + allowCleartextHttp = credentialsStore.allowCleartextHttp,
186 + )
169 187 }
170 188
171 189 // ---- Calendriers ----
M android/app/src/main/res/values-en/strings.xml
+2 -0
@@ -87,6 +87,8 @@
87 87 <string name="settings_url_placeholder">Server URL (https://…)</string>
88 88 <string name="settings_utilisateur_placeholder">Username</string>
89 89 <string name="settings_mot_de_passe_placeholder">Password</string>
90 + <string name="settings_allow_cleartext">Allow cleartext HTTP</string>
91 + <string name="settings_allow_cleartext_hint">For development, internal networks, or demos only. HTTPS is still recommended.</string>
90 92 <string name="settings_obtenir_cle">Get the key</string>
91 93 <string name="settings_retype_titre">Retype the password to decrypt</string>
92 94 <string name="settings_retype_confirmer">Confirm</string>
M android/app/src/main/res/values/strings.xml
+2 -0
@@ -87,6 +87,8 @@
87 87 <string name="settings_url_placeholder">URL du serveur (https://…)</string>
88 88 <string name="settings_utilisateur_placeholder">Nom d\'utilisateur</string>
89 89 <string name="settings_mot_de_passe_placeholder">Mot de passe</string>
90 + <string name="settings_allow_cleartext">Autoriser HTTP (clair)</string>
91 + <string name="settings_allow_cleartext_hint">Uniquement pour le développement, le réseau interne ou une démo. HTTPS reste recommandé.</string>
90 92 <string name="settings_obtenir_cle">Obtenir la clé</string>
91 93 <string name="settings_retype_titre">Retapez le mot de passe pour déchiffrer</string>
92 94 <string name="settings_retype_confirmer">Confirmer</string>
A android/app/src/main/res/xml/network_security_config.xml
+9 -0
@@ -0,0 +1,9 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<!--
3 + Cleartext autorisé au niveau plateforme pour que http:// fonctionne
4 + quand l'utilisateur coche « Autoriser HTTP (clair) » en Paramètres.
5 + La politique applicative (ServerUrlPolicy) refuse http:// par défaut.
6 +-->
7 +<network-security-config>
8 + <base-config cleartextTrafficPermitted="true" />
9 +</network-security-config>
M android/app/src/test/java/fr/ebii/card2vcf/sync/AilianceApiClientTest.kt
+38 -0
@@ -320,4 +320,42 @@ class AilianceApiClientTest {
320 320 val request = server.takeRequest()
321 321 assertTrue(request.getHeader("Authorization") == null)
322 322 }
323 +
324 + @Test
325 + fun uploadContactCarte_sendsMultipartWithBearer() {
326 + server.enqueue(
327 + MockResponse()
328 + .setResponseCode(200)
329 + .setBody("""{"id":"c1","carte_visite":"carte.jpg","cree_le":"2026-07-22T00:00:00Z"}"""),
330 + )
331 +
332 + val result = client.uploadContactCarte("c1", byteArrayOf(1, 2, 3), "card.jpg")
333 +
334 + assertTrue(result is AilianceApiClient.ApiResult.Ok)
335 + val request = server.takeRequest()
336 + assertEquals("POST", request.method)
337 + assertEquals("/api/contacts/c1/carte", request.path)
338 + assertEquals("Bearer test-bearer-key", request.getHeader("Authorization"))
339 + val body = request.body.readUtf8()
340 + assertTrue(body.contains("filename=\"card.jpg\""))
341 + assertTrue(request.getHeader("Content-Type")!!.startsWith("multipart/form-data"))
342 + }
343 +
344 + @Test
345 + fun downloadContactCarte_returnsBytes() {
346 + server.enqueue(
347 + MockResponse()
348 + .setResponseCode(200)
349 + .setHeader("Content-Type", "image/jpeg")
350 + .setBody(okio.Buffer().write(byteArrayOf(10, 20, 30))),
351 + )
352 +
353 + val result = client.downloadContactCarte("c1")
354 +
355 + assertTrue(result is AilianceApiClient.ApiResult.Ok)
356 + assertTrue((result as AilianceApiClient.ApiResult.Ok).value.contentEquals(byteArrayOf(10, 20, 30)))
357 + val request = server.takeRequest()
358 + assertEquals("GET", request.method)
359 + assertEquals("/api/contacts/c1/carte", request.path)
360 + }
323 361 }
M android/app/src/test/java/fr/ebii/card2vcf/sync/FakeAilianceApi.kt
+41 -0
@@ -80,4 +80,45 @@ class FakeAilianceApi : AilianceApi {
80 80 }
81 81 override fun listIndisponibilites(genre: String, resourceId: String) =
82 82 AilianceApiClient.ApiResult.Ok(emptyList<IndisponibiliteDto>())
83 +
84 + var uploadCarteResult: AilianceApiClient.ApiResult<String> = AilianceApiClient.ApiResult.Ok("{}")
85 + var uploadPhotoResult: AilianceApiClient.ApiResult<String> = AilianceApiClient.ApiResult.Ok("{}")
86 + val uploadCarteCalls = mutableListOf<Pair<String, Int>>()
87 + val uploadPhotoCalls = mutableListOf<Pair<String, Int>>()
88 + var downloadCarteResult: AilianceApiClient.ApiResult<ByteArray> =
89 + AilianceApiClient.ApiResult.Err(404, "absent")
90 + var downloadPhotoResult: AilianceApiClient.ApiResult<ByteArray> =
91 + AilianceApiClient.ApiResult.Err(404, "absent")
92 + val downloadCarteCalls = mutableListOf<String>()
93 + val downloadPhotoCalls = mutableListOf<String>()
94 +
95 + override fun uploadContactCarte(
96 + id: String,
97 + bytes: ByteArray,
98 + filename: String,
99 + contentType: String,
100 + ): AilianceApiClient.ApiResult<String> {
101 + uploadCarteCalls += id to bytes.size
102 + return uploadCarteResult
103 + }
104 +
105 + override fun uploadContactPhoto(
106 + id: String,
107 + bytes: ByteArray,
108 + filename: String,
109 + contentType: String,
110 + ): AilianceApiClient.ApiResult<String> {
111 + uploadPhotoCalls += id to bytes.size
112 + return uploadPhotoResult
113 + }
114 +
115 + override fun downloadContactCarte(id: String): AilianceApiClient.ApiResult<ByteArray> {
116 + downloadCarteCalls += id
117 + return downloadCarteResult
118 + }
119 +
120 + override fun downloadContactPhoto(id: String): AilianceApiClient.ApiResult<ByteArray> {
121 + downloadPhotoCalls += id
122 + return downloadPhotoResult
123 + }
83 124 }
A android/app/src/test/java/fr/ebii/card2vcf/sync/ServerUrlPolicyTest.kt
+74 -0
@@ -0,0 +1,74 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import org.junit.Assert.assertEquals
4 +import org.junit.Assert.assertFalse
5 +import org.junit.Assert.assertNull
6 +import org.junit.Assert.assertTrue
7 +import org.junit.Test
8 +
9 +/** TDD : HTTPS obligatoire sauf option « autoriser HTTP clair ». */
10 +class ServerUrlPolicyTest {
11 +
12 + @Test
13 + fun https_url_is_allowed_by_default() {
14 + assertNull(ServerUrlPolicy.validate("https://api.example.com", allowCleartext = false))
15 + assertNull(ServerUrlPolicy.validate("HTTPS://API.EXAMPLE.COM/v1", allowCleartext = false))
16 + }
17 +
18 + @Test
19 + fun http_url_rejected_when_cleartext_disallowed() {
20 + val err = ServerUrlPolicy.validate("http://192.168.1.10:3000", allowCleartext = false)
21 + assertEquals(ServerUrlPolicy.ERROR_HTTPS_REQUIRED, err)
22 + }
23 +
24 + @Test
25 + fun http_url_allowed_when_cleartext_enabled() {
26 + assertNull(ServerUrlPolicy.validate("http://192.168.1.10:3000", allowCleartext = true))
27 + assertNull(ServerUrlPolicy.validate("http://localhost:3000", allowCleartext = true))
28 + }
29 +
30 + @Test
31 + fun blank_or_missing_scheme_rejected() {
32 + assertEquals(ServerUrlPolicy.ERROR_INVALID_URL, ServerUrlPolicy.validate(" ", allowCleartext = true))
33 + assertEquals(
34 + ServerUrlPolicy.ERROR_INVALID_URL,
35 + ServerUrlPolicy.validate("api.example.com", allowCleartext = false),
36 + )
37 + assertEquals(
38 + ServerUrlPolicy.ERROR_INVALID_URL,
39 + ServerUrlPolicy.validate("ftp://api.example.com", allowCleartext = true),
40 + )
41 + }
42 +
43 + @Test
44 + fun isHttps_helper() {
45 + assertTrue(ServerUrlPolicy.isHttps("https://x"))
46 + assertFalse(ServerUrlPolicy.isHttps("http://x"))
47 + assertFalse(ServerUrlPolicy.isHttps("x"))
48 + }
49 +
50 + @Test
51 + fun lan_https_without_port_rejected() {
52 + val err = ServerUrlPolicy.validate("https://192.168.1.35", allowCleartext = false)
53 + assertEquals(ServerUrlPolicy.ERROR_LAN_HTTPS_NO_PORT, err)
54 + }
55 +
56 + @Test
57 + fun lan_http_without_port_rejected_even_when_cleartext() {
58 + val err = ServerUrlPolicy.validate("http://192.168.1.35", allowCleartext = true)
59 + assertEquals(ServerUrlPolicy.ERROR_LAN_HTTP_NO_PORT, err)
60 + }
61 +
62 + @Test
63 + fun normalizeBaseUrl_strips_version_path() {
64 + assertEquals(
65 + "http://192.168.1.35:3000",
66 + ServerUrlPolicy.normalizeBaseUrl("http://192.168.1.35:3000/version/"),
67 + )
68 + }
69 +
70 + @Test
71 + fun public_https_without_port_still_allowed() {
72 + assertNull(ServerUrlPolicy.validate("https://api.example.com", allowCleartext = false))
73 + }
74 +}
M android/app/src/test/java/fr/ebii/card2vcf/sync/SyncCredentialsStoreTest.kt
+11 -0
@@ -72,6 +72,7 @@ class SyncCredentialsStoreTest {
72 72 apiKey = "secret-bearer-token",
73 73 userName = "alice",
74 74 )
75 + store.allowCleartextHttp = true
75 76
76 77 store.clear()
77 78
@@ -79,6 +80,16 @@ class SyncCredentialsStoreTest {
79 80 assertNull(store.baseUrl)
80 81 assertNull(store.apiKey)
81 82 assertNull(store.userName)
83 + assertTrue(store.allowCleartextHttp)
84 + }
85 +
86 + @Test
87 + fun allowCleartextHttp_defaultsFalse_andRoundTrips() {
88 + assertFalse(store.allowCleartextHttp)
89 + store.allowCleartextHttp = true
90 + assertTrue(store.allowCleartextHttp)
91 + store.allowCleartextHttp = false
92 + assertFalse(store.allowCleartextHttp)
82 93 }
83 94
84 95 @Test
M android/app/src/test/java/fr/ebii/card2vcf/sync/SyncEngineTest.kt
+108 -0
@@ -153,4 +153,112 @@ class SyncEngineTest {
153 153 assertEquals(0, db.syncOpDao().listAll().size)
154 154 assertEquals("srv-generated", db.crmContactDao().getById(localId)?.serverId)
155 155 }
156 +
157 + @Test
158 + fun syncNow_pushesCardImageAfterContactCreate() = runBlocking {
159 + val ctx = ApplicationProvider.getApplicationContext<Context>()
160 + val images = fr.ebii.card2vcf.data.ContactImageStore(ctx)
161 + engine = SyncEngine(api, db, images)
162 +
163 + val localId = db.crmContactDao().insert(CrmContactEntity(fullName = "Avec carte"))
164 + val cardPath = images.saveBytes(localId, "card.jpg", byteArrayOf(1, 2, 3, 4))
165 + db.crmContactDao().update(
166 + db.crmContactDao().getById(localId)!!.copy(cardImagePath = cardPath),
167 + )
168 + db.syncOpDao().insert(
169 + SyncOpEntity(
170 + entityType = "contact",
171 + op = "create",
172 + payloadJson = """{"prenom":"A","nom":"B"}""",
173 + localId = localId,
174 + createdAt = 1L,
175 + ),
176 + )
177 +
178 + engine.syncNow()
179 +
180 + assertEquals(1, api.createContactCalls.size)
181 + assertEquals(1, api.uploadCarteCalls.size)
182 + assertEquals("srv-generated", api.uploadCarteCalls[0].first)
183 + assertEquals(4, api.uploadCarteCalls[0].second)
184 + assertEquals(0, db.syncOpDao().listAll().size)
185 + }
186 +
187 + @Test
188 + fun syncNow_enqueuesMediaRetryWhenUploadFails() = runBlocking {
189 + val ctx = ApplicationProvider.getApplicationContext<Context>()
190 + val images = fr.ebii.card2vcf.data.ContactImageStore(ctx)
191 + engine = SyncEngine(api, db, images)
192 + api.uploadCarteResult = AilianceApiClient.ApiResult.Err(500, "boom")
193 +
194 + val localId = db.crmContactDao().insert(CrmContactEntity(fullName = "Retry"))
195 + val cardPath = images.saveBytes(localId, "card.jpg", byteArrayOf(9))
196 + db.crmContactDao().update(
197 + db.crmContactDao().getById(localId)!!.copy(cardImagePath = cardPath),
198 + )
199 + db.syncOpDao().insert(
200 + SyncOpEntity(
201 + entityType = "contact",
202 + op = "create",
203 + payloadJson = """{"prenom":"A","nom":"B"}""",
204 + localId = localId,
205 + createdAt = 1L,
206 + ),
207 + )
208 +
209 + engine.syncNow()
210 +
211 + val pending = db.syncOpDao().listAll()
212 + assertEquals(1, pending.size)
213 + assertEquals("contact_media", pending[0].entityType)
214 + assertEquals("srv-generated", pending[0].serverId)
215 + }
216 +
217 + @Test
218 + fun syncNow_pullDownloadsMissingCardImage() = runBlocking {
219 + val ctx = ApplicationProvider.getApplicationContext<Context>()
220 + val images = fr.ebii.card2vcf.data.ContactImageStore(ctx)
221 + engine = SyncEngine(api, db, images)
222 + api.downloadCarteResult = AilianceApiClient.ApiResult.Ok(byteArrayOf(7, 8, 9))
223 + api.pullResult = AilianceApiClient.ApiResult.Ok(
224 + SyncPullResponse(
225 + serverTime = "2026-07-22T12:05:00Z",
226 + contacts = listOf(
227 + ContactDto(
228 + id = "c-img",
229 + prenom = "Marie",
230 + nom = "Curie",
231 + creeLe = "2026-07-20T10:00:00Z",
232 + carteVisite = "carte.jpg",
233 + ),
234 + ),
235 + ),
236 + )
237 +
238 + engine.syncNow()
239 +
240 + assertEquals(listOf("c-img"), api.downloadCarteCalls)
241 + val stored = db.crmContactDao().getByServerId("c-img")
242 + assertTrue(!stored?.cardImagePath.isNullOrBlank())
243 + assertTrue(java.io.File(stored!!.cardImagePath!!).isFile)
244 + }
245 +
246 + @Test
247 + fun syncNow_skipsImageUploadWhenNoLocalFile() = runBlocking {
248 + val localId = db.crmContactDao().insert(CrmContactEntity(fullName = "Sans image"))
249 + db.syncOpDao().insert(
250 + SyncOpEntity(
251 + entityType = "contact",
252 + op = "create",
253 + payloadJson = """{"prenom":"A","nom":"B"}""",
254 + localId = localId,
255 + createdAt = 1L,
256 + ),
257 + )
258 +
259 + engine.syncNow()
260 +
261 + assertEquals(0, api.uploadCarteCalls.size)
262 + assertEquals(0, api.uploadPhotoCalls.size)
263 + }
156 264 }
M android/app/src/test/java/fr/ebii/card2vcf/ui/settings/SettingsViewModelTest.kt
+29 -0
@@ -64,6 +64,10 @@ private class FakeAilianceApi(
64 64 override fun updateReservation(genre: String, resourceId: String, reservationId: String, jsonBody: String) = error("not used")
65 65 override fun deleteReservation(genre: String, resourceId: String, reservationId: String) = error("not used")
66 66 override fun listIndisponibilites(genre: String, resourceId: String) = error("not used")
67 + override fun uploadContactCarte(id: String, bytes: ByteArray, filename: String, contentType: String) = error("not used")
68 + override fun uploadContactPhoto(id: String, bytes: ByteArray, filename: String, contentType: String) = error("not used")
69 + override fun downloadContactCarte(id: String) = error("not used")
70 + override fun downloadContactPhoto(id: String) = error("not used")
67 71 }
68 72
69 73 @OptIn(ExperimentalCoroutinesApi::class)
@@ -153,6 +157,31 @@ class SettingsViewModelTest {
153 157 }
154 158
155 159 @Test
160 + fun obtainKey_httpUrl_rejectedUnlessCleartextAllowed() = runTest(testDispatcher) {
161 + val vm = newViewModel(authResult = AilianceApiClient.ApiResult.Ok(AuthCleResponse("a", "b", "c", "argon2id", "x")))
162 +
163 + vm.setBaseUrl("http://192.168.1.10:3000")
164 + vm.setUserName("alice")
165 + vm.setPassword("secret")
166 + vm.obtainKey()
167 + advanceUntilIdle()
168 +
169 + val rejected = vm.state.value as SettingsUiState.LoggedOut
170 + assertTrue(rejected.error!!.contains("https://"))
171 + assertFalse(rejected.loading)
172 + assertNull(rejected.pendingAuth)
173 +
174 + vm.setAllowCleartextHttp(true)
175 + vm.obtainKey()
176 + advanceUntilIdle()
177 +
178 + val allowed = vm.state.value as SettingsUiState.LoggedOut
179 + assertNull(allowed.error)
180 + assertEquals("a", allowed.pendingAuth?.nom)
181 + assertTrue(store.allowCleartextHttp)
182 + }
183 +
184 + @Test
156 185 fun confirmRetype_wrongPassword_keepsRetypeError() = runTest(testDispatcher) {
157 186 val authResponse = AuthCleResponse(
158 187 nom = "alice",