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", |
GitRust