ajout notes vocale
EBO <eric.bouhana@softalys.com> committé le 2026-09-15 20:36
70b703a84499b0a71c224cee8bad7e7c63e92127
1 parent(s)
50 fichiers modifiés
+4256
-73
M
android/app/build.gradle.kts
+68
-0
@@ -1,6 +1,7 @@
| 1 | 1 | import java.net.URI |
| 2 | 2 | import java.security.MessageDigest |
| 3 | 3 | import java.util.Properties |
| 4 | +import java.util.zip.ZipInputStream | |
| 4 | 5 | |
| 5 | 6 | plugins { |
| 6 | 7 | id("com.android.application") |
@@ -21,6 +22,17 @@ val tessdataLanguages = listOf("fra", "deu", "eng", "spa", "por", "ita", "pol")
| 21 | 22 | val tessdataCacheDir = rootProject.layout.projectDirectory.dir("tessdata-cache/$tessdataVariant") |
| 22 | 23 | val tessdataGeneratedAssets = layout.buildDirectory.dir("generated/tessdataAssets") |
| 23 | 24 | |
| 25 | +/** `standard` (défaut, ~41 Mo) ou `none` (désactive le modèle). Ex. : ./gradlew assembleDebug -Pvosk=none */ | |
| 26 | +val voskVariante: String = | |
| 27 | + (findProperty("vosk") as String?)?.lowercase()?.trim().orEmpty().ifEmpty { "standard" } | |
| 28 | +require(voskVariante == "standard" || voskVariante == "none") { | |
| 29 | + "Propriété -Pvosk= invalide (« $voskVariante ») — utiliser standard ou none" | |
| 30 | +} | |
| 31 | + | |
| 32 | +val VOSK_MODEL_NOM = "vosk-model-small-fr-0.22" | |
| 33 | +val voskCacheDir = rootProject.layout.projectDirectory.dir("vosk-cache") | |
| 34 | +val voskGeneratedAssets = layout.buildDirectory.dir("generated/voskAssets") | |
| 35 | + | |
| 24 | 36 | /** Signature release : lue depuis android/keystore.properties (hors git). |
| 25 | 37 | * Absente → assembleRelease produit un APK non signé, comme avant. */ |
| 26 | 38 | val keystoreProperties = Properties().apply { |
@@ -55,6 +67,7 @@ android {
| 55 | 67 | unitTests.isIncludeAndroidResources = true |
| 56 | 68 | } |
| 57 | 69 | sourceSets.getByName("main").assets.srcDir(tessdataGeneratedAssets) |
| 70 | + sourceSets.getByName("main").assets.srcDir(voskGeneratedAssets) | |
| 58 | 71 | signingConfigs { |
| 59 | 72 | if (keystoreProperties.getProperty("storeFile") != null) { |
| 60 | 73 | create("release") { |
@@ -176,6 +189,58 @@ val tessdataStatus by tasks.registering {
| 176 | 189 | } |
| 177 | 190 | } |
| 178 | 191 | |
| 192 | +val prepareVoskModel by tasks.registering { | |
| 193 | + group = "build" | |
| 194 | + description = "Télécharge (cache) et prépare le modèle Vosk ($VOSK_MODEL_NOM)" | |
| 195 | + inputs.property("variante", voskVariante) | |
| 196 | + outputs.dir(voskGeneratedAssets) | |
| 197 | + doLast { | |
| 198 | + val outDir = voskGeneratedAssets.get().asFile.apply { mkdirs() } | |
| 199 | + if (voskVariante == "none") { | |
| 200 | + logger.lifecycle("Vosk désactivé (-Pvosk=none) — modèle absent des assets.") | |
| 201 | + return@doLast | |
| 202 | + } | |
| 203 | + val cache = voskCacheDir.asFile.apply { mkdirs() } | |
| 204 | + val zip = cache.resolve("$VOSK_MODEL_NOM.zip") | |
| 205 | + if (!zip.isFile || zip.length() < 1_000_000L) { | |
| 206 | + val url = "https://alphacephei.com/vosk/models/$VOSK_MODEL_NOM.zip" | |
| 207 | + logger.lifecycle("Téléchargement $VOSK_MODEL_NOM.zip (~41 Mo) …") | |
| 208 | + URI(url).toURL().openStream().use { input -> | |
| 209 | + zip.outputStream().use { output -> input.copyTo(output) } | |
| 210 | + } | |
| 211 | + check(zip.isFile && zip.length() >= 1_000_000L) { | |
| 212 | + "Échec téléchargement $url" | |
| 213 | + } | |
| 214 | + } | |
| 215 | + val modelDir = outDir.resolve(VOSK_MODEL_NOM) | |
| 216 | + if (!modelDir.isDirectory) { | |
| 217 | + logger.lifecycle("Extraction $VOSK_MODEL_NOM.zip …") | |
| 218 | + ZipInputStream(zip.inputStream()).use { zis -> | |
| 219 | + var entry = zis.nextEntry | |
| 220 | + while (entry != null) { | |
| 221 | + val target = outDir.resolve(entry.name) | |
| 222 | + if (entry.isDirectory) { | |
| 223 | + target.mkdirs() | |
| 224 | + } else { | |
| 225 | + target.parentFile?.mkdirs() | |
| 226 | + target.outputStream().use { zis.copyTo(it) } | |
| 227 | + } | |
| 228 | + zis.closeEntry() | |
| 229 | + entry = zis.nextEntry | |
| 230 | + } | |
| 231 | + } | |
| 232 | + } | |
| 233 | + logger.lifecycle("Modèle Vosk $VOSK_MODEL_NOM prêt.") | |
| 234 | + } | |
| 235 | +} | |
| 236 | + | |
| 237 | +tasks.matching { | |
| 238 | + it.name.startsWith("merge") && it.name.endsWith("Assets") | |
| 239 | +}.configureEach { | |
| 240 | + dependsOn(prepareVoskModel) | |
| 241 | +} | |
| 242 | +tasks.named("preBuild").configure { dependsOn(prepareVoskModel) } | |
| 243 | + | |
| 179 | 244 | dependencies { |
| 180 | 245 | implementation(platform("androidx.compose:compose-bom:2024.09.02")) |
| 181 | 246 | implementation("androidx.compose.ui:ui") |
@@ -209,6 +274,7 @@ dependencies {
| 209 | 274 | |
| 210 | 275 | implementation("com.squareup.okhttp3:okhttp:4.12.0") |
| 211 | 276 | testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") |
| 277 | + implementation("io.noties.markwon:core:4.6.2") | |
| 212 | 278 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") |
| 213 | 279 | implementation("androidx.security:security-crypto:1.1.0-alpha06") |
| 214 | 280 | implementation("org.bouncycastle:bcprov-jdk18on:1.85") |
@@ -216,4 +282,6 @@ dependencies {
| 216 | 282 | androidTestImplementation("androidx.test.ext:junit:1.2.1") |
| 217 | 283 | androidTestImplementation("androidx.test:runner:1.6.2") |
| 218 | 284 | androidTestImplementation("androidx.test:rules:1.6.1") |
| 285 | + | |
| 286 | + implementation("com.alphacephei:vosk-android:0.3.47") | |
| 219 | 287 | } |
M
android/app/src/main/AndroidManifest.xml
+1
-0
@@ -1,6 +1,7 @@
| 1 | 1 | <?xml version="1.0" encoding="utf-8"?> |
| 2 | 2 | <manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
| 3 | 3 | <uses-permission android:name="android.permission.CAMERA" /> |
| 4 | + <uses-permission android:name="android.permission.RECORD_AUDIO" /> | |
| 4 | 5 | <uses-permission android:name="android.permission.INTERNET" /> |
| 5 | 6 | <uses-permission android:name="android.permission.READ_CALENDAR" /> |
| 6 | 7 | <uses-permission android:name="android.permission.WRITE_CALENDAR" /> |
A
android/app/src/main/java/fr/ebii/card2vcf/audio/AudioNoteRecorder.kt
+99
-0
@@ -0,0 +1,99 @@
| 1 | +package fr.ebii.card2vcf.audio | |
| 2 | + | |
| 3 | +import android.media.AudioFormat | |
| 4 | +import android.media.AudioRecord | |
| 5 | +import android.media.MediaRecorder | |
| 6 | +import android.util.Log | |
| 7 | +import java.io.File | |
| 8 | + | |
| 9 | +/** Résultat d'un enregistrement terminé. */ | |
| 10 | +data class ResultatEnregistrement(val chemin: String, val dureeMsec: Long) | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * Enregistre un fichier WAV (PCM 16 kHz mono 16 bits) via [AudioRecord]. | |
| 14 | + * | |
| 15 | + * Chaque buffer PCM capturé est aussi transmis via [onPcm] pour alimenter | |
| 16 | + * [TranscripteurVosk] en streaming pendant l'enregistrement. | |
| 17 | + * | |
| 18 | + * Durée max : 10 minutes (arrêt automatique). | |
| 19 | + * | |
| 20 | + * API : | |
| 21 | + * - [demarrer] : lance l'enregistrement dans un thread dédié. | |
| 22 | + * - [arreter] : stoppe l'enregistrement et attend la fin du thread. | |
| 23 | + * | |
| 24 | + * La demande de permission RECORD_AUDIO est à la charge de l'UI (P2-A4). | |
| 25 | + */ | |
| 26 | +class AudioNoteRecorder { | |
| 27 | + | |
| 28 | + companion object { | |
| 29 | + private const val TAG = "AudioNoteRecorder" | |
| 30 | + private const val FREQUENCE = 16_000 | |
| 31 | + private const val DUREE_MAX_MS = 10L * 60 * 1_000 | |
| 32 | + private const val CANAL = AudioFormat.CHANNEL_IN_MONO | |
| 33 | + private const val FORMAT = AudioFormat.ENCODING_PCM_16BIT | |
| 34 | + } | |
| 35 | + | |
| 36 | + @Volatile private var enCours = false | |
| 37 | + @Volatile private var dernierResultat: ResultatEnregistrement? = null | |
| 38 | + private var threadEnregistrement: Thread? = null | |
| 39 | + | |
| 40 | + /** | |
| 41 | + * Démarre l'enregistrement vers [fichierCible]. | |
| 42 | + * [onPcm] est appelé sur le thread d'enregistrement à chaque buffer capturé. | |
| 43 | + */ | |
| 44 | + fun demarrer(fichierCible: File, onPcm: (ShortArray, Int) -> Unit) { | |
| 45 | + check(!enCours) { "Enregistrement déjà en cours" } | |
| 46 | + dernierResultat = null | |
| 47 | + enCours = true | |
| 48 | + threadEnregistrement = Thread( | |
| 49 | + { executer(fichierCible, onPcm) }, | |
| 50 | + "AudioNoteRecorder", | |
| 51 | + ).also { it.start() } | |
| 52 | + } | |
| 53 | + | |
| 54 | + /** | |
| 55 | + * Stoppe l'enregistrement et attend la fin du thread (max 3 s). | |
| 56 | + * @return Le résultat de l'enregistrement, ou null si non démarré. | |
| 57 | + */ | |
| 58 | + fun arreter(): ResultatEnregistrement? { | |
| 59 | + enCours = false | |
| 60 | + threadEnregistrement?.join(3_000) | |
| 61 | + threadEnregistrement = null | |
| 62 | + return dernierResultat | |
| 63 | + } | |
| 64 | + | |
| 65 | + private fun executer(fichierCible: File, onPcm: (ShortArray, Int) -> Unit) { | |
| 66 | + val tailleBuffer = AudioRecord.getMinBufferSize(FREQUENCE, CANAL, FORMAT) | |
| 67 | + .coerceAtLeast(FREQUENCE * 2) // >= 0,5 s de tampon | |
| 68 | + val record = AudioRecord( | |
| 69 | + MediaRecorder.AudioSource.MIC, | |
| 70 | + FREQUENCE, | |
| 71 | + CANAL, | |
| 72 | + FORMAT, | |
| 73 | + tailleBuffer, | |
| 74 | + ) | |
| 75 | + val ecriture = EcritureWav(fichierCible, FREQUENCE) | |
| 76 | + val buffer = ShortArray(tailleBuffer / 2) | |
| 77 | + val debut = System.currentTimeMillis() | |
| 78 | + try { | |
| 79 | + record.startRecording() | |
| 80 | + while (enCours && System.currentTimeMillis() - debut < DUREE_MAX_MS) { | |
| 81 | + val lu = record.read(buffer, 0, buffer.size) | |
| 82 | + if (lu > 0) { | |
| 83 | + ecriture.ecrireEchantillons(buffer, lu) | |
| 84 | + onPcm(buffer, lu) | |
| 85 | + } | |
| 86 | + } | |
| 87 | + } catch (e: Exception) { | |
| 88 | + Log.e(TAG, "Erreur pendant l'enregistrement", e) | |
| 89 | + } finally { | |
| 90 | + record.stop() | |
| 91 | + record.release() | |
| 92 | + ecriture.fermer() | |
| 93 | + dernierResultat = ResultatEnregistrement( | |
| 94 | + chemin = fichierCible.absolutePath, | |
| 95 | + dureeMsec = System.currentTimeMillis() - debut, | |
| 96 | + ) | |
| 97 | + } | |
| 98 | + } | |
| 99 | +} |
A
android/app/src/main/java/fr/ebii/card2vcf/audio/EcritureWav.kt
+71
-0
@@ -0,0 +1,71 @@
| 1 | +package fr.ebii.card2vcf.audio | |
| 2 | + | |
| 3 | +import java.io.File | |
| 4 | +import java.io.RandomAccessFile | |
| 5 | +import java.nio.ByteBuffer | |
| 6 | +import java.nio.ByteOrder | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Écrit un fichier WAV PCM (16 kHz mono 16 bits par défaut). | |
| 10 | + * | |
| 11 | + * Séparée de [AudioNoteRecorder] pour être testable en JVM pur, sans dépendance Android. | |
| 12 | + * L'en-tête RIFF/fmt/data est réécrit à la clôture avec les tailles exactes. | |
| 13 | + */ | |
| 14 | +class EcritureWav( | |
| 15 | + private val fichier: File, | |
| 16 | + private val frequence: Int = 16_000, | |
| 17 | +) { | |
| 18 | + private val raf = RandomAccessFile(fichier, "rw") | |
| 19 | + private var octetsData: Long = 0L | |
| 20 | + | |
| 21 | + init { | |
| 22 | + // Réserve 44 octets pour l'en-tête ; sera réécrit à fermer() | |
| 23 | + raf.write(ByteArray(44)) | |
| 24 | + } | |
| 25 | + | |
| 26 | + /** Écrit des échantillons PCM 16 bits signés (little-endian). */ | |
| 27 | + fun ecrireEchantillons(data: ShortArray, longueur: Int) { | |
| 28 | + val buf = ByteArray(longueur * 2) | |
| 29 | + val bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN) | |
| 30 | + for (i in 0 until longueur) bb.putShort(data[i]) | |
| 31 | + raf.write(buf) | |
| 32 | + octetsData += longueur * 2L | |
| 33 | + } | |
| 34 | + | |
| 35 | + /** | |
| 36 | + * Finalise l'en-tête WAV et ferme le fichier. | |
| 37 | + * @return Nombre d'octets de données PCM écrits. | |
| 38 | + */ | |
| 39 | + fun fermer(): Long { | |
| 40 | + // 10 min à 16 kHz 16 bits mono ≈ 18,3 Mo — largement sous Int.MAX_VALUE. | |
| 41 | + // La garde protège contre un enregistrement pathologiquement long ou un bug de comptage. | |
| 42 | + check(octetsData <= Int.MAX_VALUE) { "Fichier WAV trop grand : $octetsData octets (max ${Int.MAX_VALUE})" } | |
| 43 | + val tailleData = octetsData.toInt() | |
| 44 | + // tailleFichier = octets après "RIFF"+int32 = 4(WAVE)+8(fmt tag+size)+16(fmt)+8(data tag+size)+data | |
| 45 | + val tailleFichier = tailleData + 36 | |
| 46 | + val canaux = 1 | |
| 47 | + val bitsParEchantillon = 16 | |
| 48 | + val byteRate = frequence * canaux * bitsParEchantillon / 8 | |
| 49 | + val blockAlign = canaux * bitsParEchantillon / 8 | |
| 50 | + | |
| 51 | + val entete = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN) | |
| 52 | + entete.put("RIFF".toByteArray(Charsets.US_ASCII)) | |
| 53 | + entete.putInt(tailleFichier) | |
| 54 | + entete.put("WAVE".toByteArray(Charsets.US_ASCII)) | |
| 55 | + entete.put("fmt ".toByteArray(Charsets.US_ASCII)) | |
| 56 | + entete.putInt(16) // taille du chunk fmt | |
| 57 | + entete.putShort(1) // format PCM | |
| 58 | + entete.putShort(canaux.toShort()) | |
| 59 | + entete.putInt(frequence) | |
| 60 | + entete.putInt(byteRate) | |
| 61 | + entete.putShort(blockAlign.toShort()) | |
| 62 | + entete.putShort(bitsParEchantillon.toShort()) | |
| 63 | + entete.put("data".toByteArray(Charsets.US_ASCII)) | |
| 64 | + entete.putInt(tailleData) | |
| 65 | + | |
| 66 | + raf.seek(0) | |
| 67 | + raf.write(entete.array()) | |
| 68 | + raf.close() | |
| 69 | + return octetsData | |
| 70 | + } | |
| 71 | +} |
A
android/app/src/main/java/fr/ebii/card2vcf/audio/TranscripteurLocal.kt
+32
-0
@@ -0,0 +1,32 @@
| 1 | +package fr.ebii.card2vcf.audio | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Interface de transcription locale (ASR sur l'appareil). | |
| 5 | + * | |
| 6 | + * Implémentée par [TranscripteurVosk] ; un fake est fourni dans les tests. | |
| 7 | + * Quand le modèle est absent des assets (build `-Pvosk=none`), l'[etat] vaut | |
| 8 | + * [Etat.INDISPONIBLE] et l'UI doit forcer le mode serveur. | |
| 9 | + */ | |
| 10 | +interface TranscripteurLocal { | |
| 11 | + | |
| 12 | + enum class Etat { DISPONIBLE, INDISPONIBLE } | |
| 13 | + | |
| 14 | + val etat: Etat | |
| 15 | + | |
| 16 | + /** | |
| 17 | + * Alimente le moteur avec un buffer PCM capturé par [AudioNoteRecorder]. | |
| 18 | + * Appelé sur le thread d'enregistrement. | |
| 19 | + * | |
| 20 | + * @return Un résultat partiel si disponible, null sinon. | |
| 21 | + */ | |
| 22 | + fun accepterEchantillons(data: ShortArray, longueur: Int): String? | |
| 23 | + | |
| 24 | + /** | |
| 25 | + * Demande le résultat final après la fin de l'enregistrement. | |
| 26 | + * @return Le texte transcrit (peut être vide). | |
| 27 | + */ | |
| 28 | + fun finaliser(): String | |
| 29 | + | |
| 30 | + /** Réinitialise l'état interne pour une nouvelle session. */ | |
| 31 | + fun reinitialiser() | |
| 32 | +} |
A
android/app/src/main/java/fr/ebii/card2vcf/audio/TranscripteurVosk.kt
+135
-0
@@ -0,0 +1,135 @@
| 1 | +package fr.ebii.card2vcf.audio | |
| 2 | + | |
| 3 | +import android.content.Context | |
| 4 | +import android.util.Log | |
| 5 | +import kotlinx.serialization.json.Json | |
| 6 | +import kotlinx.serialization.json.JsonPrimitive | |
| 7 | +import kotlinx.serialization.json.jsonObject | |
| 8 | +import org.vosk.Model | |
| 9 | +import org.vosk.Recognizer | |
| 10 | +import java.io.File | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * Transcription locale via le moteur Vosk (modèle français small ~41 Mo). | |
| 14 | + * | |
| 15 | + * Sur le premier appel à [accepterEchantillons], le modèle est extrait des assets | |
| 16 | + * vers [Context.filesDir] si ce n'est pas déjà fait. | |
| 17 | + * | |
| 18 | + * Si le modèle est absent des assets (build `-Pvosk=none`), [etat] vaut | |
| 19 | + * [TranscripteurLocal.Etat.INDISPONIBLE] et toutes les méthodes de transcription | |
| 20 | + * renvoient des valeurs vides/null sans planter. | |
| 21 | + */ | |
| 22 | +class TranscripteurVosk(private val context: Context) : TranscripteurLocal { | |
| 23 | + | |
| 24 | + companion object { | |
| 25 | + private const val TAG = "TranscripteurVosk" | |
| 26 | + private const val NOM_MODELE = "vosk-model-small-fr-0.22" | |
| 27 | + private const val FREQUENCE = 16_000f | |
| 28 | + | |
| 29 | + private val json = Json { ignoreUnknownKeys = true } | |
| 30 | + | |
| 31 | + /** | |
| 32 | + * Extrait la valeur de la clé "partial" du JSON Vosk. | |
| 33 | + * Retourne null si le texte est vide ou le JSON invalide. | |
| 34 | + */ | |
| 35 | + fun parsagePartiel(jsonStr: String): String? = runCatching { | |
| 36 | + (json.parseToJsonElement(jsonStr).jsonObject["partial"] as? JsonPrimitive) | |
| 37 | + ?.content | |
| 38 | + ?.takeIf { it.isNotEmpty() } | |
| 39 | + }.getOrNull() | |
| 40 | + | |
| 41 | + /** | |
| 42 | + * Extrait la valeur de la clé "text" du JSON Vosk. | |
| 43 | + * Retourne une chaîne vide si le JSON est invalide. | |
| 44 | + */ | |
| 45 | + fun parsageTexte(jsonStr: String): String = runCatching { | |
| 46 | + (json.parseToJsonElement(jsonStr).jsonObject["text"] as? JsonPrimitive) | |
| 47 | + ?.content | |
| 48 | + ?: "" | |
| 49 | + }.getOrDefault("") | |
| 50 | + } | |
| 51 | + | |
| 52 | + override val etat: TranscripteurLocal.Etat by lazy { detecterEtat() } | |
| 53 | + | |
| 54 | + private var modele: Model? = null | |
| 55 | + private var reconnaisseur: Recognizer? = null | |
| 56 | + | |
| 57 | + private fun detecterEtat(): TranscripteurLocal.Etat { | |
| 58 | + val disponible = runCatching { | |
| 59 | + context.assets.list("")?.contains(NOM_MODELE) == true | |
| 60 | + }.getOrDefault(false) | |
| 61 | + if (!disponible) { | |
| 62 | + Log.w(TAG, "Modèle Vosk absent des assets — mode serveur requis (build -Pvosk=none ?)") | |
| 63 | + } | |
| 64 | + return if (disponible) TranscripteurLocal.Etat.DISPONIBLE else TranscripteurLocal.Etat.INDISPONIBLE | |
| 65 | + } | |
| 66 | + | |
| 67 | + /** Retourne le [Recognizer] prêt à l'emploi, ou null si modèle indisponible. */ | |
| 68 | + private fun obtenirReconnaisseur(): Recognizer? { | |
| 69 | + if (etat == TranscripteurLocal.Etat.INDISPONIBLE) return null | |
| 70 | + if (reconnaisseur != null) return reconnaisseur | |
| 71 | + return runCatching { | |
| 72 | + val repModele = extraireModele() | |
| 73 | + val m = Model(repModele.absolutePath).also { modele = it } | |
| 74 | + Recognizer(m, FREQUENCE).also { reconnaisseur = it } | |
| 75 | + }.onFailure { e -> | |
| 76 | + Log.e(TAG, "Impossible d'initialiser le moteur Vosk", e) | |
| 77 | + }.getOrNull() | |
| 78 | + } | |
| 79 | + | |
| 80 | + /** Retourne le répertoire du modèle dans filesDir, après extraction si nécessaire. */ | |
| 81 | + private fun extraireModele(): File { | |
| 82 | + val dest = File(context.filesDir, NOM_MODELE) | |
| 83 | + if (dest.isDirectory && dest.list()?.isNotEmpty() == true) return dest | |
| 84 | + Log.i(TAG, "Extraction du modèle Vosk vers ${dest.absolutePath} …") | |
| 85 | + copierRepertoireAssets(NOM_MODELE, dest) | |
| 86 | + Log.i(TAG, "Modèle Vosk extrait.") | |
| 87 | + return dest | |
| 88 | + } | |
| 89 | + | |
| 90 | + private fun copierRepertoireAssets(cheminAsset: String, destDir: File) { | |
| 91 | + destDir.mkdirs() | |
| 92 | + val enfants = context.assets.list(cheminAsset) ?: return | |
| 93 | + for (enfant in enfants) { | |
| 94 | + val sousAsset = "$cheminAsset/$enfant" | |
| 95 | + val sousDest = File(destDir, enfant) | |
| 96 | + val sousEnfants = context.assets.list(sousAsset) ?: emptyArray() | |
| 97 | + if (sousEnfants.isEmpty()) { | |
| 98 | + // fichier feuille | |
| 99 | + context.assets.open(sousAsset).use { input -> | |
| 100 | + sousDest.outputStream().use { input.copyTo(it) } | |
| 101 | + } | |
| 102 | + } else { | |
| 103 | + copierRepertoireAssets(sousAsset, sousDest) | |
| 104 | + } | |
| 105 | + } | |
| 106 | + } | |
| 107 | + | |
| 108 | + override fun accepterEchantillons(data: ShortArray, longueur: Int): String? { | |
| 109 | + val rec = obtenirReconnaisseur() ?: return null | |
| 110 | + val octets = shortArrayVersOctets(data, longueur) | |
| 111 | + val complet = rec.acceptWaveForm(octets, octets.size) | |
| 112 | + return if (complet) parsageTexte(rec.result) else parsagePartiel(rec.partialResult) | |
| 113 | + } | |
| 114 | + | |
| 115 | + override fun finaliser(): String { | |
| 116 | + val rec = reconnaisseur ?: return "" | |
| 117 | + return parsageTexte(rec.finalResult) | |
| 118 | + } | |
| 119 | + | |
| 120 | + override fun reinitialiser() { | |
| 121 | + reconnaisseur?.close() | |
| 122 | + reconnaisseur = null | |
| 123 | + } | |
| 124 | + | |
| 125 | + /** Convertit un tableau de Short PCM en ByteArray little-endian. */ | |
| 126 | + private fun shortArrayVersOctets(data: ShortArray, longueur: Int): ByteArray { | |
| 127 | + val octets = ByteArray(longueur * 2) | |
| 128 | + for (i in 0 until longueur) { | |
| 129 | + val s = data[i].toInt() | |
| 130 | + octets[i * 2] = (s and 0xFF).toByte() | |
| 131 | + octets[i * 2 + 1] = (s ushr 8 and 0xFF).toByte() | |
| 132 | + } | |
| 133 | + return octets | |
| 134 | + } | |
| 135 | +} |
A
android/app/src/main/java/fr/ebii/card2vcf/data/AudioNoteStore.kt
+62
-0
@@ -0,0 +1,62 @@
| 1 | +package fr.ebii.card2vcf.data | |
| 2 | + | |
| 3 | +import android.content.Context | |
| 4 | +import java.io.File | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Stockage local des fichiers audio WAV téléchargés depuis le serveur. | |
| 8 | + * Le téléchargement est à la demande (P2-A4) ; cette classe expose uniquement | |
| 9 | + * les primitives save/path/delete — le pull n'y touche pas. | |
| 10 | + * | |
| 11 | + * Pattern : [ContactImageStore] mais pour les WAV d'interactions et de notes. | |
| 12 | + */ | |
| 13 | +class AudioNoteStore(private val context: Context) { | |
| 14 | + | |
| 15 | + private fun interactionDir(localId: Long): File = | |
| 16 | + File(context.filesDir, "interactions/$localId").also { it.mkdirs() } | |
| 17 | + | |
| 18 | + private fun noteDir(localId: Long): File = | |
| 19 | + File(context.filesDir, "notes_audio/$localId").also { it.mkdirs() } | |
| 20 | + | |
| 21 | + /** Enregistre les octets d'un audio d'interaction ; retourne le chemin absolu. */ | |
| 22 | + fun saveInteractionAudio(localId: Long, filename: String, bytes: ByteArray): String { | |
| 23 | + val file = File(interactionDir(localId), filename) | |
| 24 | + file.writeBytes(bytes) | |
| 25 | + return file.absolutePath | |
| 26 | + } | |
| 27 | + | |
| 28 | + /** Enregistre les octets d'un audio de note projet ; retourne le chemin absolu. */ | |
| 29 | + fun saveNoteAudio(localId: Long, filename: String, bytes: ByteArray): String { | |
| 30 | + val file = File(noteDir(localId), filename) | |
| 31 | + file.writeBytes(bytes) | |
| 32 | + return file.absolutePath | |
| 33 | + } | |
| 34 | + | |
| 35 | + fun deleteInteractionAudio(localId: Long) { | |
| 36 | + interactionDir(localId).deleteRecursively() | |
| 37 | + } | |
| 38 | + | |
| 39 | + fun deleteNoteAudio(localId: Long) { | |
| 40 | + noteDir(localId).deleteRecursively() | |
| 41 | + } | |
| 42 | + | |
| 43 | + /** Déplace [fichierTemp] vers le dossier de l'interaction ; retourne le chemin absolu final. */ | |
| 44 | + fun deplacerInteractionAudio(localId: Long, fichierTemp: File): String = | |
| 45 | + deplacerFichierAudio(fichierTemp, File(interactionDir(localId), "audio.wav")) | |
| 46 | + | |
| 47 | + /** Déplace [fichierTemp] vers le dossier de la note projet ; retourne le chemin absolu final. */ | |
| 48 | + fun deplacerNoteAudio(localId: Long, fichierTemp: File): String = | |
| 49 | + deplacerFichierAudio(fichierTemp, File(noteDir(localId), "audio.wav")) | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** | |
| 53 | + * Copie [fichierTemp] vers [cible] (en créant les dossiers parents), supprime la source, | |
| 54 | + * et retourne le chemin absolu de la cible. | |
| 55 | + * Fonction interne extraite pour les tests JVM (pas de dépendance Android). | |
| 56 | + */ | |
| 57 | +internal fun deplacerFichierAudio(fichierTemp: File, cible: File): String { | |
| 58 | + cible.parentFile?.mkdirs() | |
| 59 | + fichierTemp.copyTo(cible, overwrite = true) | |
| 60 | + fichierTemp.delete() | |
| 61 | + return cible.absolutePath | |
| 62 | +} |
M
android/app/src/main/java/fr/ebii/card2vcf/data/CrmDatabase.kt
+42
-2
@@ -27,8 +27,9 @@ import fr.ebii.card2vcf.sync.SyncOpEntity
| 27 | 27 | RdvEntity::class, |
| 28 | 28 | ReservationEntity::class, |
| 29 | 29 | IndisponibiliteEntity::class, |
| 30 | + NoteProjetEntity::class, | |
| 30 | 31 | ], |
| 31 | - version = 4, | |
| 32 | + version = 7, | |
| 32 | 33 | exportSchema = false, |
| 33 | 34 | ) |
| 34 | 35 | @TypeConverters(Converters::class) |
@@ -44,6 +45,7 @@ abstract class CrmDatabase : RoomDatabase() {
| 44 | 45 | abstract fun rdvDao(): RdvDao |
| 45 | 46 | abstract fun reservationDao(): ReservationDao |
| 46 | 47 | abstract fun indisponibiliteDao(): IndisponibiliteDao |
| 48 | + abstract fun noteProjetDao(): NoteProjetDao | |
| 47 | 49 | |
| 48 | 50 | companion object { |
| 49 | 51 | @Volatile private var instance: CrmDatabase? = null |
@@ -55,6 +57,44 @@ abstract class CrmDatabase : RoomDatabase() {
| 55 | 57 | } |
| 56 | 58 | } |
| 57 | 59 | |
| 60 | + /** v5→v6 : colonnes `audioPath` et `transcriptionStatut` sur la table `interactions`. */ | |
| 61 | + val MIGRATION_5_6 = object : Migration(5, 6) { | |
| 62 | + override fun migrate(db: SupportSQLiteDatabase) { | |
| 63 | + db.execSQL("ALTER TABLE interactions ADD COLUMN audioPath TEXT") | |
| 64 | + db.execSQL("ALTER TABLE interactions ADD COLUMN transcriptionStatut TEXT") | |
| 65 | + } | |
| 66 | + } | |
| 67 | + | |
| 68 | + /** v6→v7 : colonne `transcriptionErreur` sur `interactions` et `notes_projet`. */ | |
| 69 | + val MIGRATION_6_7 = object : Migration(6, 7) { | |
| 70 | + override fun migrate(db: SupportSQLiteDatabase) { | |
| 71 | + db.execSQL("ALTER TABLE interactions ADD COLUMN transcriptionErreur TEXT") | |
| 72 | + db.execSQL("ALTER TABLE notes_projet ADD COLUMN transcriptionErreur TEXT") | |
| 73 | + } | |
| 74 | + } | |
| 75 | + | |
| 76 | + /** v4→v5 : table `notes_projet` (notes markdown de projet, champs audio pour phase 2). */ | |
| 77 | + val MIGRATION_4_5 = object : Migration(4, 5) { | |
| 78 | + override fun migrate(db: SupportSQLiteDatabase) { | |
| 79 | + db.execSQL( | |
| 80 | + """ | |
| 81 | + CREATE TABLE IF NOT EXISTS notes_projet ( | |
| 82 | + localId INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, | |
| 83 | + serverId TEXT, | |
| 84 | + projetServerId TEXT NOT NULL, | |
| 85 | + titre TEXT NOT NULL, | |
| 86 | + texte TEXT NOT NULL, | |
| 87 | + audioPath TEXT, | |
| 88 | + transcriptionStatut TEXT, | |
| 89 | + auteur TEXT NOT NULL, | |
| 90 | + createdAt INTEGER NOT NULL, | |
| 91 | + updatedAt INTEGER | |
| 92 | + ) | |
| 93 | + """.trimIndent(), | |
| 94 | + ) | |
| 95 | + } | |
| 96 | + } | |
| 97 | + | |
| 58 | 98 | /** |
| 59 | 99 | * v1/v2/v3 sync schema : pas de migration incrémentale — reset local à l'upgrade (acceptable v1-v3). |
| 60 | 100 | * À partir de v3→v4 les migrations sont additives ; le fallback destructif reste en filet. |
@@ -67,7 +107,7 @@ abstract class CrmDatabase : RoomDatabase() {
| 67 | 107 | CrmDatabase::class.java, |
| 68 | 108 | "card2vcf-crm.db", |
| 69 | 109 | ) |
| 70 | - .addMigrations(MIGRATION_3_4) | |
| 110 | + .addMigrations(MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7) | |
| 71 | 111 | .fallbackToDestructiveMigration() |
| 72 | 112 | .build() |
| 73 | 113 | .also { instance = it } |
M
android/app/src/main/java/fr/ebii/card2vcf/data/InteractionDao.kt
+10
-0
@@ -4,6 +4,7 @@ import androidx.room.Dao
| 4 | 4 | import androidx.room.Insert |
| 5 | 5 | import androidx.room.OnConflictStrategy |
| 6 | 6 | import androidx.room.Query |
| 7 | +import androidx.room.Update | |
| 7 | 8 | import kotlinx.coroutines.flow.Flow |
| 8 | 9 | |
| 9 | 10 | @Dao |
@@ -11,12 +12,21 @@ interface InteractionDao {
| 11 | 12 | @Insert(onConflict = OnConflictStrategy.REPLACE) |
| 12 | 13 | suspend fun upsert(entity: InteractionEntity): Long |
| 13 | 14 | |
| 15 | + @Update | |
| 16 | + suspend fun update(entity: InteractionEntity) | |
| 17 | + | |
| 18 | + @Query("SELECT * FROM interactions WHERE localId = :localId") | |
| 19 | + suspend fun getByLocalId(localId: Long): InteractionEntity? | |
| 20 | + | |
| 14 | 21 | @Query("SELECT * FROM interactions WHERE serverId = :serverId") |
| 15 | 22 | suspend fun getByServerId(serverId: String): InteractionEntity? |
| 16 | 23 | |
| 17 | 24 | @Query("DELETE FROM interactions WHERE serverId = :serverId") |
| 18 | 25 | suspend fun deleteByServerId(serverId: String) |
| 19 | 26 | |
| 27 | + @Query("DELETE FROM interactions WHERE localId = :localId") | |
| 28 | + suspend fun deleteByLocalId(localId: Long) | |
| 29 | + | |
| 20 | 30 | @Query("SELECT * FROM interactions WHERE contactServerId = :contactServerId") |
| 21 | 31 | suspend fun listByContactServerId(contactServerId: String): List<InteractionEntity> |
| 22 | 32 |
M
android/app/src/main/java/fr/ebii/card2vcf/data/InteractionEntity.kt
+6
-0
@@ -14,4 +14,10 @@ data class InteractionEntity(
| 14 | 14 | val creePar: String = "", |
| 15 | 15 | val createdAt: Long = 0L, |
| 16 | 16 | val updatedAt: Long? = null, |
| 17 | + /** Chemin local vers le fichier audio WAV (null si pas de note vocale). */ | |
| 18 | + val audioPath: String? = null, | |
| 19 | + /** Statut de transcription : null | "en_attente" | "terminee" | "echec". */ | |
| 20 | + val transcriptionStatut: String? = null, | |
| 21 | + /** Message d'erreur de transcription côté serveur (null si pas d'erreur). */ | |
| 22 | + val transcriptionErreur: String? = null, | |
| 17 | 23 | ) |
A
android/app/src/main/java/fr/ebii/card2vcf/data/NoteProjetDao.kt
+31
-0
@@ -0,0 +1,31 @@
| 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 | +import kotlinx.coroutines.flow.Flow | |
| 8 | + | |
| 9 | +@Dao | |
| 10 | +interface NoteProjetDao { | |
| 11 | + @Insert(onConflict = OnConflictStrategy.REPLACE) | |
| 12 | + suspend fun upsert(entity: NoteProjetEntity): Long | |
| 13 | + | |
| 14 | + @Query("SELECT * FROM notes_projet WHERE serverId = :serverId") | |
| 15 | + suspend fun getByServerId(serverId: String): NoteProjetEntity? | |
| 16 | + | |
| 17 | + @Query("SELECT * FROM notes_projet WHERE localId = :localId") | |
| 18 | + suspend fun getByLocalId(localId: Long): NoteProjetEntity? | |
| 19 | + | |
| 20 | + @Query("DELETE FROM notes_projet WHERE serverId = :serverId") | |
| 21 | + suspend fun deleteByServerId(serverId: String) | |
| 22 | + | |
| 23 | + @Query("DELETE FROM notes_projet WHERE localId = :localId") | |
| 24 | + suspend fun deleteByLocalId(localId: Long) | |
| 25 | + | |
| 26 | + @Query("SELECT * FROM notes_projet WHERE projetServerId = :projetServerId ORDER BY createdAt DESC") | |
| 27 | + fun listByProjetServerId(projetServerId: String): Flow<List<NoteProjetEntity>> | |
| 28 | + | |
| 29 | + @Query("SELECT * FROM notes_projet") | |
| 30 | + suspend fun listAll(): List<NoteProjetEntity> | |
| 31 | +} |
A
android/app/src/main/java/fr/ebii/card2vcf/data/NoteProjetEntity.kt
+22
-0
@@ -0,0 +1,22 @@
| 1 | +package fr.ebii.card2vcf.data | |
| 2 | + | |
| 3 | +import androidx.room.Entity | |
| 4 | +import androidx.room.PrimaryKey | |
| 5 | + | |
| 6 | +@Entity(tableName = "notes_projet") | |
| 7 | +data class NoteProjetEntity( | |
| 8 | + @PrimaryKey(autoGenerate = true) val localId: Long = 0, | |
| 9 | + val serverId: String? = null, | |
| 10 | + val projetServerId: String = "", | |
| 11 | + val titre: String = "", | |
| 12 | + val texte: String = "", | |
| 13 | + /** Chemin local vers le fichier audio WAV (null en phase 1, préparé pour phase 2). */ | |
| 14 | + val audioPath: String? = null, | |
| 15 | + /** Statut de transcription : null | "en_attente" | "terminee" | "echec" (null en phase 1). */ | |
| 16 | + val transcriptionStatut: String? = null, | |
| 17 | + /** Message d'erreur de transcription côté serveur (null si pas d'erreur). */ | |
| 18 | + val transcriptionErreur: String? = null, | |
| 19 | + val auteur: String = "", | |
| 20 | + val createdAt: Long = 0L, | |
| 21 | + val updatedAt: Long? = null, | |
| 22 | +) |
M
android/app/src/main/java/fr/ebii/card2vcf/sync/AilianceApi.kt
+32
-0
@@ -36,6 +36,12 @@ interface AilianceApi {
| 36 | 36 | |
| 37 | 37 | fun createInteraction(contactId: String, jsonBody: String): AilianceApiClient.ApiResult<String> |
| 38 | 38 | |
| 39 | + fun deleteInteraction(iid: String): AilianceApiClient.ApiResult<Unit> | |
| 40 | + | |
| 41 | + fun relancerTranscriptionInteraction(contactServerId: String, iid: String): AilianceApiClient.ApiResult<Unit> | |
| 42 | + | |
| 43 | + fun relancerTranscriptionNote(projetServerId: String, nid: String): AilianceApiClient.ApiResult<Unit> | |
| 44 | + | |
| 39 | 45 | fun listRdv(): AilianceApiClient.ApiResult<List<RendezVousDto>> |
| 40 | 46 | |
| 41 | 47 | fun createRdv(jsonBody: String): AilianceApiClient.ApiResult<String> |
@@ -80,4 +86,30 @@ interface AilianceApi {
| 80 | 86 | fun downloadContactCarte(id: String): AilianceApiClient.ApiResult<ByteArray> |
| 81 | 87 | |
| 82 | 88 | fun downloadContactPhoto(id: String): AilianceApiClient.ApiResult<ByteArray> |
| 89 | + | |
| 90 | + fun uploadInteractionAudio( | |
| 91 | + contactId: String, | |
| 92 | + iid: String, | |
| 93 | + bytes: ByteArray, | |
| 94 | + filename: String, | |
| 95 | + contentType: String = "audio/wav", | |
| 96 | + ): AilianceApiClient.ApiResult<String> | |
| 97 | + | |
| 98 | + fun downloadInteractionAudio(contactId: String, iid: String): AilianceApiClient.ApiResult<ByteArray> | |
| 99 | + | |
| 100 | + fun createNoteProjet(projetId: String, jsonBody: String): AilianceApiClient.ApiResult<String> | |
| 101 | + | |
| 102 | + fun updateNoteProjet(projetId: String, nid: String, jsonBody: String): AilianceApiClient.ApiResult<String> | |
| 103 | + | |
| 104 | + fun deleteNoteProjet(projetId: String, nid: String): AilianceApiClient.ApiResult<Unit> | |
| 105 | + | |
| 106 | + fun uploadNoteAudio( | |
| 107 | + projetId: String, | |
| 108 | + nid: String, | |
| 109 | + bytes: ByteArray, | |
| 110 | + filename: String, | |
| 111 | + contentType: String = "audio/wav", | |
| 112 | + ): AilianceApiClient.ApiResult<String> | |
| 113 | + | |
| 114 | + fun downloadNoteAudio(projetId: String, nid: String): AilianceApiClient.ApiResult<ByteArray> | |
| 83 | 115 | } |
M
android/app/src/main/java/fr/ebii/card2vcf/sync/AilianceApiClient.kt
+78
-0
@@ -84,6 +84,27 @@ class AilianceApiClient(
| 84 | 84 | override fun createInteraction(contactId: String, jsonBody: String): ApiResult<String> = |
| 85 | 85 | post("/api/contacts/${encodePath(contactId)}/interactions", jsonBody) { it } |
| 86 | 86 | |
| 87 | + override fun deleteInteraction(iid: String): ApiResult<Unit> = | |
| 88 | + delete("/api/interactions/${encodePath(iid)}") | |
| 89 | + | |
| 90 | + override fun relancerTranscriptionInteraction(contactServerId: String, iid: String): ApiResult<Unit> = | |
| 91 | + when (val r = post( | |
| 92 | + "/api/contacts/${encodePath(contactServerId)}/interactions/${encodePath(iid)}/transcription", | |
| 93 | + "{}", | |
| 94 | + ) { it }) { | |
| 95 | + is ApiResult.Ok -> ApiResult.Ok(Unit) | |
| 96 | + is ApiResult.Err -> r | |
| 97 | + } | |
| 98 | + | |
| 99 | + override fun relancerTranscriptionNote(projetServerId: String, nid: String): ApiResult<Unit> = | |
| 100 | + when (val r = post( | |
| 101 | + "/api/projets/${encodePath(projetServerId)}/notes/${encodePath(nid)}/transcription", | |
| 102 | + "{}", | |
| 103 | + ) { it }) { | |
| 104 | + is ApiResult.Ok -> ApiResult.Ok(Unit) | |
| 105 | + is ApiResult.Err -> r | |
| 106 | + } | |
| 107 | + | |
| 87 | 108 | override fun listRdv(): ApiResult<List<RendezVousDto>> = |
| 88 | 109 | get("/api/rdv") { body -> syncJson.decodeFromString(ListSerializer(RendezVousDto.serializer()), body) } |
| 89 | 110 |
@@ -151,6 +172,63 @@ class AilianceApiClient(
| 151 | 172 | override fun downloadContactPhoto(id: String): ApiResult<ByteArray> = |
| 152 | 173 | executeBytes(buildRequest("GET", "/api/contacts/${encodePath(id)}/photo", null, useBearer = true)) |
| 153 | 174 | |
| 175 | + override fun uploadInteractionAudio( | |
| 176 | + contactId: String, | |
| 177 | + iid: String, | |
| 178 | + bytes: ByteArray, | |
| 179 | + filename: String, | |
| 180 | + contentType: String, | |
| 181 | + ): ApiResult<String> = | |
| 182 | + uploadMultipart( | |
| 183 | + "/api/contacts/${encodePath(contactId)}/interactions/${encodePath(iid)}/audio", | |
| 184 | + bytes, | |
| 185 | + filename, | |
| 186 | + contentType, | |
| 187 | + ) | |
| 188 | + | |
| 189 | + override fun downloadInteractionAudio(contactId: String, iid: String): ApiResult<ByteArray> = | |
| 190 | + executeBytes( | |
| 191 | + buildRequest( | |
| 192 | + "GET", | |
| 193 | + "/api/contacts/${encodePath(contactId)}/interactions/${encodePath(iid)}/audio", | |
| 194 | + null, | |
| 195 | + useBearer = true, | |
| 196 | + ), | |
| 197 | + ) | |
| 198 | + | |
| 199 | + override fun createNoteProjet(projetId: String, jsonBody: String): ApiResult<String> = | |
| 200 | + post("/api/projets/${encodePath(projetId)}/notes", jsonBody) { it } | |
| 201 | + | |
| 202 | + override fun updateNoteProjet(projetId: String, nid: String, jsonBody: String): ApiResult<String> = | |
| 203 | + put("/api/projets/${encodePath(projetId)}/notes/${encodePath(nid)}", jsonBody) { it } | |
| 204 | + | |
| 205 | + override fun deleteNoteProjet(projetId: String, nid: String): ApiResult<Unit> = | |
| 206 | + delete("/api/projets/${encodePath(projetId)}/notes/${encodePath(nid)}") | |
| 207 | + | |
| 208 | + override fun uploadNoteAudio( | |
| 209 | + projetId: String, | |
| 210 | + nid: String, | |
| 211 | + bytes: ByteArray, | |
| 212 | + filename: String, | |
| 213 | + contentType: String, | |
| 214 | + ): ApiResult<String> = | |
| 215 | + uploadMultipart( | |
| 216 | + "/api/projets/${encodePath(projetId)}/notes/${encodePath(nid)}/audio", | |
| 217 | + bytes, | |
| 218 | + filename, | |
| 219 | + contentType, | |
| 220 | + ) | |
| 221 | + | |
| 222 | + override fun downloadNoteAudio(projetId: String, nid: String): ApiResult<ByteArray> = | |
| 223 | + executeBytes( | |
| 224 | + buildRequest( | |
| 225 | + "GET", | |
| 226 | + "/api/projets/${encodePath(projetId)}/notes/${encodePath(nid)}/audio", | |
| 227 | + null, | |
| 228 | + useBearer = true, | |
| 229 | + ), | |
| 230 | + ) | |
| 231 | + | |
| 154 | 232 | private fun uploadMultipart( |
| 155 | 233 | path: String, |
| 156 | 234 | bytes: ByteArray, |
M
android/app/src/main/java/fr/ebii/card2vcf/sync/LwwMerger.kt
+4
-0
@@ -18,6 +18,10 @@ object LwwMerger {
| 18 | 18 | fun shouldInsertInteraction(existingServerIds: Set<String>, remoteServerId: String): Boolean = |
| 19 | 19 | remoteServerId !in existingServerIds |
| 20 | 20 | |
| 21 | + /** LWW update : true si le distant est strictement plus récent que le local. */ | |
| 22 | + fun shouldUpdateInteraction(localTs: Long, remoteTs: Long): Boolean = | |
| 23 | + remoteTs > localTs | |
| 24 | + | |
| 21 | 25 | /** Caller deletes local rows by serverId; returns remaining ids after tombstone application. */ |
| 22 | 26 | fun applyTombstoneIds(localServerIds: Set<String>, tombstoneIds: Set<String>): Set<String> = |
| 23 | 27 | localServerIds - tombstoneIds |
M
android/app/src/main/java/fr/ebii/card2vcf/sync/SyncCredentialsStore.kt
+12
-0
@@ -45,6 +45,17 @@ class SyncCredentialsStore internal constructor(
| 45 | 45 | prefs.edit().putBoolean(KEY_ALLOW_CLEARTEXT, value).apply() |
| 46 | 46 | } |
| 47 | 47 | |
| 48 | + /** | |
| 49 | + * Mode de transcription des notes vocales. | |
| 50 | + * `true` = sur l'appareil (Vosk, défaut) ; `false` = sur le serveur. | |
| 51 | + * Conservé après [clear] : préférence locale non sensible. | |
| 52 | + */ | |
| 53 | + var modeTranscriptionLocal: Boolean | |
| 54 | + get() = prefs.getBoolean(KEY_MODE_TRANSCRIPTION_LOCAL, true) | |
| 55 | + set(value) { | |
| 56 | + prefs.edit().putBoolean(KEY_MODE_TRANSCRIPTION_LOCAL, value).apply() | |
| 57 | + } | |
| 58 | + | |
| 48 | 59 | fun save(baseUrl: String, apiKey: String, userName: String) { |
| 49 | 60 | prefs.edit() |
| 50 | 61 | .putString(KEY_BASE_URL, baseUrl) |
@@ -71,6 +82,7 @@ class SyncCredentialsStore internal constructor(
| 71 | 82 | const val KEY_API_KEY = "api_key" |
| 72 | 83 | const val KEY_USER_NAME = "user_name" |
| 73 | 84 | const val KEY_ALLOW_CLEARTEXT = "allow_cleartext_http" |
| 85 | + const val KEY_MODE_TRANSCRIPTION_LOCAL = "mode_transcription_local" | |
| 74 | 86 | |
| 75 | 87 | internal fun createEncryptedPrefs(context: Context): SharedPreferences = |
| 76 | 88 | EncryptedSharedPreferences.create( |
M
android/app/src/main/java/fr/ebii/card2vcf/sync/SyncEngine.kt
+234
-18
@@ -4,6 +4,7 @@ import fr.ebii.card2vcf.data.CrmContactEntity
| 4 | 4 | import fr.ebii.card2vcf.data.CrmDatabase |
| 5 | 5 | import fr.ebii.card2vcf.data.EntrepriseEntity |
| 6 | 6 | import fr.ebii.card2vcf.data.InteractionEntity |
| 7 | +import fr.ebii.card2vcf.data.NoteProjetEntity | |
| 7 | 8 | import fr.ebii.card2vcf.data.ProjetEntity |
| 8 | 9 | import fr.ebii.card2vcf.data.TacheEntity |
| 9 | 10 | import fr.ebii.card2vcf.data.WorkflowEntity |
@@ -40,6 +41,9 @@ data class SyncResult(
| 40 | 41 | val pushFailures: List<PushFailure> = emptyList(), |
| 41 | 42 | ) |
| 42 | 43 | |
| 44 | +/** Résultat d'un push immédiat (sans pull) via [SyncEngine.pousserEnAttente]. */ | |
| 45 | +data class RapportPush(val envoyes: Int, val echecs: Int) | |
| 46 | + | |
| 43 | 47 | /** |
| 44 | 48 | * Pousse la file [SyncOpEntity] puis tire `/api/sync/pull` ; LWW / append / tombstones. |
| 45 | 49 | * Le pont Agenda↔Room (RDV + réservations/indispos liés à des calendriers Android) est délégué à |
@@ -70,6 +74,15 @@ class SyncEngine(
| 70 | 74 | } |
| 71 | 75 | |
| 72 | 76 | /** |
| 77 | + * Pousse uniquement les ops en file, sans pull ni pont Agenda. | |
| 78 | + * Utilisé pour un envoi immédiat après création d'une note vocale en mode serveur. | |
| 79 | + */ | |
| 80 | + suspend fun pousserEnAttente(): RapportPush = withContext(Dispatchers.IO) { | |
| 81 | + val rapport = pushOps() | |
| 82 | + RapportPush(envoyes = rapport.succeeded, echecs = rapport.failures.size) | |
| 83 | + } | |
| 84 | + | |
| 85 | + /** | |
| 73 | 86 | * [bindings]/[bridge] vides ou `null` : chemin CRM v1 inchangé (pas de pont Agenda, `ressources=` omis). |
| 74 | 87 | */ |
| 75 | 88 | suspend fun syncNow( |
@@ -186,6 +199,9 @@ class SyncEngine(
| 186 | 199 | "projet" -> pushProjetOp(op) |
| 187 | 200 | "tache" -> pushTacheOp(op) |
| 188 | 201 | "interaction" -> pushInteractionOp(op) |
| 202 | + ENTITY_INTERACTION_MEDIA -> pushInteractionMediaOp(op) | |
| 203 | + "note_projet" -> pushNoteProjetOp(op) | |
| 204 | + ENTITY_NOTE_MEDIA -> pushNoteMediaOp(op) | |
| 189 | 205 | AgendaSyncCoordinator.KIND_RDV -> pushRdvOp(op) |
| 190 | 206 | AgendaSyncCoordinator.ENTITY_RESERVATION -> pushReservationOp(op) |
| 191 | 207 | else -> PushOutcome(true) |
@@ -351,11 +367,146 @@ class SyncEngine(
| 351 | 367 | } |
| 352 | 368 | } |
| 353 | 369 | |
| 354 | - /** `op.serverId` porte le `serverId` du contact cible (les interactions n'ont pas d'update/delete). */ | |
| 370 | + /** | |
| 371 | + * `op.serverId` porte le `serverId` du contact cible (create) ou l'iid de l'interaction (delete). | |
| 372 | + * Après create réussi : sauvegarde le serverId et tente l'upload audio. | |
| 373 | + */ | |
| 355 | 374 | private suspend fun pushInteractionOp(op: SyncOpEntity): PushOutcome { |
| 356 | - if (op.op != "create") return PushOutcome(true) | |
| 357 | - val contactServerId = op.serverId ?: return PushOutcome(false) | |
| 358 | - return api.createInteraction(contactServerId, op.payloadJson).toOutcome() | |
| 375 | + return when (op.op) { | |
| 376 | + "create" -> { | |
| 377 | + val contactServerId = op.serverId ?: return PushOutcome(false) | |
| 378 | + val result = api.createInteraction(contactServerId, op.payloadJson) | |
| 379 | + if (result is AilianceApiClient.ApiResult.Ok) { | |
| 380 | + val newServerId = parseCreatedId(result.value) | |
| 381 | + if (newServerId != null && op.localId != null) { | |
| 382 | + db.interactionDao().getByLocalId(op.localId)?.let { | |
| 383 | + db.interactionDao().update(it.copy(serverId = newServerId)) | |
| 384 | + } | |
| 385 | + if (!pushInteractionAudio(op.localId, newServerId)) { | |
| 386 | + enqueueInteractionMediaRetry(op.localId, newServerId) | |
| 387 | + } | |
| 388 | + } | |
| 389 | + } | |
| 390 | + result.toOutcome() | |
| 391 | + } | |
| 392 | + "delete" -> { | |
| 393 | + val iid = op.serverId ?: return PushOutcome(false) | |
| 394 | + api.deleteInteraction(iid).toOutcome() | |
| 395 | + } | |
| 396 | + else -> PushOutcome(true) | |
| 397 | + } | |
| 398 | + } | |
| 399 | + | |
| 400 | + private suspend fun pushInteractionMediaOp(op: SyncOpEntity): PushOutcome { | |
| 401 | + val interactionServerId = op.serverId ?: return PushOutcome(true) | |
| 402 | + val localId = op.localId | |
| 403 | + ?: db.interactionDao().getByServerId(interactionServerId)?.localId | |
| 404 | + ?: return PushOutcome(true) | |
| 405 | + return PushOutcome(pushInteractionAudio(localId, interactionServerId)) | |
| 406 | + } | |
| 407 | + | |
| 408 | + /** Upload audio d'une interaction. `true` si rien à envoyer ou upload OK. */ | |
| 409 | + private suspend fun pushInteractionAudio(localId: Long, interactionServerId: String): Boolean { | |
| 410 | + val interaction = db.interactionDao().getByLocalId(localId) ?: return true | |
| 411 | + val audioPath = interaction.audioPath?.takeIf { it.isNotBlank() } ?: return true | |
| 412 | + val file = java.io.File(audioPath) | |
| 413 | + if (!file.isFile) return true | |
| 414 | + val result = api.uploadInteractionAudio( | |
| 415 | + interaction.contactServerId, | |
| 416 | + interactionServerId, | |
| 417 | + file.readBytes(), | |
| 418 | + "audio.wav", | |
| 419 | + ) | |
| 420 | + return result is AilianceApiClient.ApiResult.Ok | |
| 421 | + } | |
| 422 | + | |
| 423 | + private suspend fun enqueueInteractionMediaRetry(localId: Long, interactionServerId: String) { | |
| 424 | + val already = db.syncOpDao().findByEntityTypeAndLocalId(ENTITY_INTERACTION_MEDIA, localId) != null | |
| 425 | + if (already) return | |
| 426 | + db.syncOpDao().insert( | |
| 427 | + SyncOpEntity( | |
| 428 | + entityType = ENTITY_INTERACTION_MEDIA, | |
| 429 | + op = "upload", | |
| 430 | + payloadJson = "{}", | |
| 431 | + localId = localId, | |
| 432 | + serverId = interactionServerId, | |
| 433 | + createdAt = System.currentTimeMillis(), | |
| 434 | + ), | |
| 435 | + ) | |
| 436 | + } | |
| 437 | + | |
| 438 | + private suspend fun resolveNoteProjetServerId(op: SyncOpEntity): String? { | |
| 439 | + op.localId?.let { db.noteProjetDao().getByLocalId(it)?.let { n -> return n.projetServerId } } | |
| 440 | + op.serverId?.let { db.noteProjetDao().getByServerId(it)?.let { n -> return n.projetServerId } } | |
| 441 | + return null | |
| 442 | + } | |
| 443 | + | |
| 444 | + private suspend fun pushNoteProjetOp(op: SyncOpEntity): PushOutcome { | |
| 445 | + return when (op.op) { | |
| 446 | + "create" -> { | |
| 447 | + val projetId = resolveNoteProjetServerId(op) ?: return PushOutcome(false) | |
| 448 | + val result = api.createNoteProjet(projetId, op.payloadJson) | |
| 449 | + if (result is AilianceApiClient.ApiResult.Ok) { | |
| 450 | + val newServerId = parseCreatedId(result.value) | |
| 451 | + if (newServerId != null && op.localId != null) { | |
| 452 | + db.noteProjetDao().getByLocalId(op.localId)?.let { | |
| 453 | + db.noteProjetDao().upsert(it.copy(serverId = newServerId)) | |
| 454 | + } | |
| 455 | + if (!pushNoteAudio(op.localId, newServerId, projetId)) { | |
| 456 | + enqueueNoteMediaRetry(op.localId, newServerId) | |
| 457 | + } | |
| 458 | + } | |
| 459 | + } | |
| 460 | + result.toOutcome() | |
| 461 | + } | |
| 462 | + "update" -> { | |
| 463 | + val noteServerId = op.serverId ?: return PushOutcome(false) | |
| 464 | + val projetId = resolveNoteProjetServerId(op) ?: return PushOutcome(false) | |
| 465 | + api.updateNoteProjet(projetId, noteServerId, op.payloadJson).toOutcome() | |
| 466 | + } | |
| 467 | + "delete" -> { | |
| 468 | + val noteServerId = op.serverId ?: return PushOutcome(false) | |
| 469 | + val projetId = resolveNoteProjetServerId(op) ?: return PushOutcome(false) | |
| 470 | + api.deleteNoteProjet(projetId, noteServerId).toOutcome() | |
| 471 | + } | |
| 472 | + else -> PushOutcome(true) | |
| 473 | + } | |
| 474 | + } | |
| 475 | + | |
| 476 | + private suspend fun pushNoteMediaOp(op: SyncOpEntity): PushOutcome { | |
| 477 | + val noteServerId = op.serverId ?: return PushOutcome(true) | |
| 478 | + val localId = op.localId | |
| 479 | + ?: db.noteProjetDao().getByServerId(noteServerId)?.localId | |
| 480 | + ?: return PushOutcome(true) | |
| 481 | + val projetId = db.noteProjetDao().getByLocalId(localId)?.projetServerId ?: return PushOutcome(true) | |
| 482 | + return PushOutcome(pushNoteAudio(localId, noteServerId, projetId)) | |
| 483 | + } | |
| 484 | + | |
| 485 | + /** Upload audio d'une note projet. `true` si rien à envoyer ou upload OK. */ | |
| 486 | + private suspend fun pushNoteAudio(localId: Long, noteServerId: String, projetId: String): Boolean { | |
| 487 | + val note = db.noteProjetDao().getByLocalId(localId) ?: return true | |
| 488 | + val audioPath = note.audioPath?.takeIf { it.isNotBlank() } ?: return true | |
| 489 | + val file = java.io.File(audioPath) | |
| 490 | + if (!file.isFile) return true | |
| 491 | + val result = api.uploadNoteAudio(projetId, noteServerId, file.readBytes(), "audio.wav") | |
| 492 | + return result is AilianceApiClient.ApiResult.Ok | |
| 493 | + } | |
| 494 | + | |
| 495 | + private suspend fun enqueueNoteMediaRetry(localId: Long, noteServerId: String) { | |
| 496 | + val already = db.syncOpDao().listAll().any { | |
| 497 | + it.entityType == ENTITY_NOTE_MEDIA && it.localId == localId | |
| 498 | + } | |
| 499 | + if (already) return | |
| 500 | + db.syncOpDao().insert( | |
| 501 | + SyncOpEntity( | |
| 502 | + entityType = ENTITY_NOTE_MEDIA, | |
| 503 | + op = "upload", | |
| 504 | + payloadJson = "{}", | |
| 505 | + localId = localId, | |
| 506 | + serverId = noteServerId, | |
| 507 | + createdAt = System.currentTimeMillis(), | |
| 508 | + ), | |
| 509 | + ) | |
| 359 | 510 | } |
| 360 | 511 | |
| 361 | 512 | private suspend fun pushRdvOp(op: SyncOpEntity): PushOutcome = when (op.op) { |
@@ -446,13 +597,14 @@ class SyncEngine(
| 446 | 597 | pull.projets.forEach { applyProjet(it) } |
| 447 | 598 | pull.taches.forEach { applyTache(it) } |
| 448 | 599 | pull.interactions.forEach { applyInteraction(it) } |
| 600 | + pull.notes.forEach { applyNoteProjet(it) } | |
| 449 | 601 | pull.workflows.forEach { applyWorkflow(it) } |
| 450 | 602 | pull.rdv.forEach { agenda.applyRdvPull(it) } |
| 451 | 603 | pull.reservations.forEach { agenda.applyReservationPull(it) } |
| 452 | 604 | pull.indisponibilites.forEach { agenda.applyIndisponibilitePull(it) } |
| 453 | 605 | pull.tombstones.forEach { applyTombstone(it) } |
| 454 | 606 | return pull.contacts.size + pull.entreprises.size + pull.projets.size + pull.taches.size + |
| 455 | - pull.interactions.size + pull.rdv.size + pull.reservations.size + pull.indisponibilites.size | |
| 607 | + pull.interactions.size + pull.notes.size + pull.rdv.size + pull.reservations.size + pull.indisponibilites.size | |
| 456 | 608 | } |
| 457 | 609 | |
| 458 | 610 | private suspend fun applyContact(dto: ContactDto) { |
@@ -638,19 +790,80 @@ class SyncEngine(
| 638 | 790 | |
| 639 | 791 | private suspend fun applyInteraction(dto: InteractionDto) { |
| 640 | 792 | val existing = db.interactionDao().listByContactServerId(dto.contactId).mapNotNull { it.serverId }.toSet() |
| 641 | - if (!LwwMerger.shouldInsertInteraction(existing, dto.id)) return | |
| 642 | - db.interactionDao().upsert( | |
| 643 | - InteractionEntity( | |
| 644 | - serverId = dto.id, | |
| 645 | - contactServerId = dto.contactId, | |
| 646 | - type = dto.typeInteraction, | |
| 647 | - sujet = dto.sujet, | |
| 648 | - description = dto.description, | |
| 649 | - creePar = dto.creePar, | |
| 650 | - createdAt = parseIsoToEpochMs(dto.creeLe), | |
| 651 | - updatedAt = dto.misAJourLe?.let { parseIsoToEpochMs(it) }, | |
| 652 | - ), | |
| 653 | - ) | |
| 793 | + if (LwwMerger.shouldInsertInteraction(existing, dto.id)) { | |
| 794 | + db.interactionDao().upsert( | |
| 795 | + InteractionEntity( | |
| 796 | + serverId = dto.id, | |
| 797 | + contactServerId = dto.contactId, | |
| 798 | + type = dto.typeInteraction, | |
| 799 | + sujet = dto.sujet, | |
| 800 | + description = dto.description, | |
| 801 | + creePar = dto.creePar, | |
| 802 | + transcriptionStatut = dto.transcription, | |
| 803 | + transcriptionErreur = dto.transcriptionErreur, | |
| 804 | + createdAt = parseIsoToEpochMs(dto.creeLe), | |
| 805 | + updatedAt = dto.misAJourLe?.let { parseIsoToEpochMs(it) }, | |
| 806 | + ), | |
| 807 | + ) | |
| 808 | + return | |
| 809 | + } | |
| 810 | + // Déjà connue : mise à jour LWW si le distant est plus récent | |
| 811 | + val local = db.interactionDao().getByServerId(dto.id) ?: return | |
| 812 | + val remoteTs = dto.misAJourLe?.let { parseIsoToEpochMs(it) } ?: return | |
| 813 | + val localTs = local.updatedAt ?: local.createdAt | |
| 814 | + if (LwwMerger.shouldUpdateInteraction(localTs, remoteTs)) { | |
| 815 | + db.interactionDao().upsert( | |
| 816 | + local.copy( | |
| 817 | + type = dto.typeInteraction, | |
| 818 | + sujet = dto.sujet, | |
| 819 | + description = dto.description, | |
| 820 | + transcriptionStatut = dto.transcription, | |
| 821 | + transcriptionErreur = dto.transcriptionErreur, | |
| 822 | + updatedAt = remoteTs, | |
| 823 | + // audioPath préservé : local.copy() ne l'écrase pas | |
| 824 | + ), | |
| 825 | + ) | |
| 826 | + } | |
| 827 | + } | |
| 828 | + | |
| 829 | + /** | |
| 830 | + * Insert si [dto.id] inconnu ; met à jour si le timestamp distant ([majLe] ou [creeLe]) | |
| 831 | + * est plus récent que [NoteProjetEntity.updatedAt] local ; préserve [NoteProjetEntity.audioPath]. | |
| 832 | + */ | |
| 833 | + private suspend fun applyNoteProjet(dto: NoteProjetDto) { | |
| 834 | + val remoteTs = parseIsoToEpochMs(dto.majLe ?: dto.creeLe) | |
| 835 | + val local = db.noteProjetDao().getByServerId(dto.id) | |
| 836 | + if (local == null) { | |
| 837 | + db.noteProjetDao().upsert( | |
| 838 | + NoteProjetEntity( | |
| 839 | + serverId = dto.id, | |
| 840 | + projetServerId = dto.projetId, | |
| 841 | + titre = dto.titre, | |
| 842 | + texte = dto.contenu, | |
| 843 | + auteur = dto.auteur, | |
| 844 | + createdAt = parseIsoToEpochMs(dto.creeLe), | |
| 845 | + updatedAt = remoteTs, | |
| 846 | + transcriptionStatut = dto.transcription, | |
| 847 | + transcriptionErreur = dto.transcriptionErreur, | |
| 848 | + ), | |
| 849 | + ) | |
| 850 | + return | |
| 851 | + } | |
| 852 | + val localTs = local.updatedAt ?: local.createdAt | |
| 853 | + if (remoteTs > localTs) { | |
| 854 | + db.noteProjetDao().upsert( | |
| 855 | + local.copy( | |
| 856 | + projetServerId = dto.projetId, | |
| 857 | + titre = dto.titre, | |
| 858 | + texte = dto.contenu, | |
| 859 | + auteur = dto.auteur, | |
| 860 | + updatedAt = remoteTs, | |
| 861 | + transcriptionStatut = dto.transcription, | |
| 862 | + transcriptionErreur = dto.transcriptionErreur, | |
| 863 | + // audioPath préservé : local.copy() ne l'écrase pas | |
| 864 | + ), | |
| 865 | + ) | |
| 866 | + } | |
| 654 | 867 | } |
| 655 | 868 | |
| 656 | 869 | private suspend fun applyWorkflow(dto: WorkflowDto) { |
@@ -677,6 +890,7 @@ class SyncEngine(
| 677 | 890 | } |
| 678 | 891 | "tache" -> db.tacheDao().deleteByServerId(dto.id) |
| 679 | 892 | "interaction" -> db.interactionDao().deleteByServerId(dto.id) |
| 893 | + "note" -> db.noteProjetDao().deleteByServerId(dto.id) | |
| 680 | 894 | else -> agenda.applyAgendaTombstone(dto) |
| 681 | 895 | } |
| 682 | 896 | } |
@@ -686,6 +900,8 @@ class SyncEngine(
| 686 | 900 | const val WATERMARK_ORIGIN_KEY = "watermark_origin" |
| 687 | 901 | const val DEFAULT_WATERMARK = "1970-01-01T00:00:00Z" |
| 688 | 902 | const val ENTITY_CONTACT_MEDIA = "contact_media" |
| 903 | + const val ENTITY_INTERACTION_MEDIA = "interaction_media" | |
| 904 | + const val ENTITY_NOTE_MEDIA = "note_media" | |
| 689 | 905 | |
| 690 | 906 | fun <T> AilianceApiClient.ApiResult<T>.isOk(): Boolean = this is AilianceApiClient.ApiResult.Ok |
| 691 | 907 |
A
android/app/src/main/java/fr/ebii/card2vcf/sync/SyncEngineFactory.kt
+14
-0
@@ -0,0 +1,14 @@
| 1 | +package fr.ebii.card2vcf.sync | |
| 2 | + | |
| 3 | +import fr.ebii.card2vcf.data.CrmDatabase | |
| 4 | + | |
| 5 | +/** Crée un [SyncEngine] à partir des identifiants stockés. `null` si l'app n'est pas configurée. */ | |
| 6 | +fun creerSyncEngine(credentialsStore: SyncCredentialsStore, database: CrmDatabase): SyncEngine? { | |
| 7 | + val baseUrl = credentialsStore.baseUrl ?: return null | |
| 8 | + val apiKey = credentialsStore.apiKey ?: return null | |
| 9 | + return SyncEngine( | |
| 10 | + AilianceApiClient(baseUrl, apiKey), | |
| 11 | + database, | |
| 12 | + serverIdentity = "$baseUrl|${credentialsStore.userName.orEmpty()}", | |
| 13 | + ) | |
| 14 | +} |
M
android/app/src/main/java/fr/ebii/card2vcf/sync/SyncModels.kt
+43
-0
@@ -66,6 +66,8 @@ data class SyncPullResponse(
| 66 | 66 | val indisponibilites: List<IndisponibiliteDto> = emptyList(), |
| 67 | 67 | val tombstones: List<TombstoneDto> = emptyList(), |
| 68 | 68 | val workflows: List<WorkflowDto> = emptyList(), |
| 69 | + /** Notes markdown des projets ; défaut `emptyList()` = compat anciens serveurs sans ce champ. */ | |
| 70 | + val notes: List<NoteProjetDto> = emptyList(), | |
| 69 | 71 | ) |
| 70 | 72 | |
| 71 | 73 | @Serializable |
@@ -153,6 +155,12 @@ data class InteractionDto(
| 153 | 155 | val creePar: String = "", |
| 154 | 156 | val creeLe: String, |
| 155 | 157 | val misAJourLe: String? = null, |
| 158 | + /** Nom de fichier audio côté serveur (ex. `interaction_uuid.wav`). */ | |
| 159 | + val pieceJointe: String? = null, | |
| 160 | + /** Statut de transcription : "en_attente" | "terminee" | "echec" | null. */ | |
| 161 | + val transcription: String? = null, | |
| 162 | + /** Message d'erreur de transcription côté serveur (null si pas d'erreur). */ | |
| 163 | + val transcriptionErreur: String? = null, | |
| 156 | 164 | ) |
| 157 | 165 | |
| 158 | 166 | @Serializable |
@@ -213,6 +221,24 @@ data class IndisponibiliteDto(
| 213 | 221 | val misAJourLe: String? = null, |
| 214 | 222 | ) |
| 215 | 223 | |
| 224 | +/** Note markdown d'un projet (contrat P1-S1 / P1-A2 / P2-A3). */ | |
| 225 | +@Serializable | |
| 226 | +data class NoteProjetDto( | |
| 227 | + val id: String, | |
| 228 | + val projetId: String, | |
| 229 | + val titre: String, | |
| 230 | + val contenu: String, | |
| 231 | + val auteur: String = "", | |
| 232 | + val creeLe: String, | |
| 233 | + val majLe: String? = null, | |
| 234 | + /** Nom de fichier audio côté serveur (ex. `note_uuid.wav`), null si aucun audio. */ | |
| 235 | + val audio: String? = null, | |
| 236 | + /** Statut de transcription : "en_attente" | "terminee" | "echec" | null. */ | |
| 237 | + val transcription: String? = null, | |
| 238 | + /** Message d'erreur de transcription côté serveur (null si pas d'erreur). */ | |
| 239 | + val transcriptionErreur: String? = null, | |
| 240 | +) | |
| 241 | + | |
| 216 | 242 | /** Ressource catalogue allégée (salle/matériel/véhicule) : champs communs uniquement. */ |
| 217 | 243 | @Serializable |
| 218 | 244 | data class RessourceItemDto( |
@@ -267,6 +293,8 @@ data class MoveTacheRequest(
| 267 | 293 | data class CreateInteractionRequest( |
| 268 | 294 | val sujet: String, |
| 269 | 295 | val description: String = "", |
| 296 | + val typeInteraction: String = "note", | |
| 297 | + val demandeTranscription: Boolean = false, | |
| 270 | 298 | ) |
| 271 | 299 | |
| 272 | 300 | @Serializable |
@@ -320,3 +348,18 @@ data class ReservationUpsertRequest(
| 320 | 348 | val projetId: String? = null, |
| 321 | 349 | val rdvId: String? = null, |
| 322 | 350 | ) |
| 351 | + | |
| 352 | +/** Payload création note projet (body `POST /api/projets/:id/notes`). */ | |
| 353 | +@Serializable | |
| 354 | +data class CreateNoteProjetRequest( | |
| 355 | + val titre: String, | |
| 356 | + val contenu: String = "", | |
| 357 | + val demandeTranscription: Boolean = false, | |
| 358 | +) | |
| 359 | + | |
| 360 | +/** Payload mise à jour note projet (body `PUT /api/projets/:id/notes/:nid`). */ | |
| 361 | +@Serializable | |
| 362 | +data class UpdateNoteProjetRequest( | |
| 363 | + val titre: String, | |
| 364 | + val contenu: String = "", | |
| 365 | +) |
M
android/app/src/main/java/fr/ebii/card2vcf/sync/SyncOpDao.kt
+3
-0
@@ -25,4 +25,7 @@ interface SyncOpDao {
| 25 | 25 | |
| 26 | 26 | @Query("DELETE FROM sync_ops WHERE id = :id") |
| 27 | 27 | suspend fun deleteById(id: Long) |
| 28 | + | |
| 29 | + @Query("SELECT * FROM sync_ops WHERE entityType = :entityType AND localId = :localId LIMIT 1") | |
| 30 | + suspend fun findByEntityTypeAndLocalId(entityType: String, localId: Long): SyncOpEntity? | |
| 28 | 31 | } |
A
android/app/src/main/java/fr/ebii/card2vcf/ui/audio/ConfirmationNote.kt
+4
-0
@@ -0,0 +1,4 @@
| 1 | +package fr.ebii.card2vcf.ui.audio | |
| 2 | + | |
| 3 | +/** Résultat de l'ajout d'une note vocale — permet à l'écran d'afficher le message de confirmation adapté. */ | |
| 4 | +enum class ConfirmationNote { APPAREIL, SERVEUR_OK, SERVEUR_REPLI } |
A
android/app/src/main/java/fr/ebii/card2vcf/ui/audio/EnregistrementNoteSheet.kt
+431
-0
@@ -0,0 +1,431 @@
| 1 | +package fr.ebii.card2vcf.ui.audio | |
| 2 | + | |
| 3 | +import android.Manifest | |
| 4 | +import android.content.pm.PackageManager | |
| 5 | +import android.media.MediaPlayer | |
| 6 | +import androidx.activity.compose.rememberLauncherForActivityResult | |
| 7 | +import androidx.activity.result.contract.ActivityResultContracts | |
| 8 | +import androidx.compose.foundation.layout.Arrangement | |
| 9 | +import androidx.compose.foundation.layout.Box | |
| 10 | +import androidx.compose.foundation.layout.Column | |
| 11 | +import androidx.compose.foundation.layout.Row | |
| 12 | +import androidx.compose.foundation.layout.Spacer | |
| 13 | +import androidx.compose.foundation.layout.fillMaxWidth | |
| 14 | +import androidx.compose.foundation.layout.height | |
| 15 | +import androidx.compose.foundation.layout.padding | |
| 16 | +import androidx.compose.foundation.layout.size | |
| 17 | +import androidx.compose.material.icons.Icons | |
| 18 | +import androidx.compose.material.icons.filled.Mic | |
| 19 | +import androidx.compose.material.icons.filled.Pause | |
| 20 | +import androidx.compose.material.icons.filled.PlayArrow | |
| 21 | +import androidx.compose.material.icons.filled.Stop | |
| 22 | +import androidx.compose.material3.Button | |
| 23 | +import androidx.compose.material3.CircularProgressIndicator | |
| 24 | +import androidx.compose.material3.ExperimentalMaterial3Api | |
| 25 | +import androidx.compose.material3.Icon | |
| 26 | +import androidx.compose.material3.IconButton | |
| 27 | +import androidx.compose.material3.MaterialTheme | |
| 28 | +import androidx.compose.material3.ModalBottomSheet | |
| 29 | +import androidx.compose.material3.OutlinedButton | |
| 30 | +import androidx.compose.material3.Text | |
| 31 | +import androidx.compose.material3.rememberModalBottomSheetState | |
| 32 | +import androidx.compose.runtime.Composable | |
| 33 | +import androidx.compose.runtime.DisposableEffect | |
| 34 | +import androidx.compose.runtime.LaunchedEffect | |
| 35 | +import androidx.compose.runtime.getValue | |
| 36 | +import androidx.compose.runtime.mutableStateOf | |
| 37 | +import androidx.compose.runtime.remember | |
| 38 | +import androidx.compose.runtime.rememberCoroutineScope | |
| 39 | +import androidx.compose.runtime.setValue | |
| 40 | +import androidx.compose.ui.Alignment | |
| 41 | +import androidx.compose.ui.Modifier | |
| 42 | +import androidx.compose.ui.platform.LocalContext | |
| 43 | +import androidx.compose.ui.res.stringResource | |
| 44 | +import androidx.compose.ui.unit.dp | |
| 45 | +import androidx.core.content.ContextCompat | |
| 46 | +import fr.ebii.card2vcf.R | |
| 47 | +import fr.ebii.card2vcf.audio.AudioNoteRecorder | |
| 48 | +import fr.ebii.card2vcf.audio.TranscripteurVosk | |
| 49 | +import fr.ebii.card2vcf.ui.composants.ChampTexte | |
| 50 | +import fr.ebii.card2vcf.ui.theme.Bordure | |
| 51 | +import fr.ebii.card2vcf.ui.theme.Fond | |
| 52 | +import fr.ebii.card2vcf.ui.theme.Ink | |
| 53 | +import fr.ebii.card2vcf.ui.theme.TexteFaible | |
| 54 | +import kotlinx.coroutines.Dispatchers | |
| 55 | +import kotlinx.coroutines.delay | |
| 56 | +import kotlinx.coroutines.launch | |
| 57 | +import kotlinx.coroutines.withContext | |
| 58 | +import java.io.File | |
| 59 | + | |
| 60 | +/** Formate une durée en ms en "mm:ss". */ | |
| 61 | +fun formaterDuree(ms: Long): String { | |
| 62 | + val totalSec = ms / 1000L | |
| 63 | + val min = totalSec / 60L | |
| 64 | + val sec = totalSec % 60L | |
| 65 | + return "%02d:%02d".format(min, sec) | |
| 66 | +} | |
| 67 | + | |
| 68 | +private enum class Phase { EN_ATTENTE_PERMISSION, EN_COURS, ARRET, EDITION } | |
| 69 | + | |
| 70 | +/** | |
| 71 | + * Feuille d'enregistrement modale commune (contact et projet). | |
| 72 | + * | |
| 73 | + * Demande la permission RECORD_AUDIO, démarre l'enregistrement WAV, affiche le timer et la | |
| 74 | + * transcription partielle Vosk si disponible, puis propose l'édition du texte avant sauvegarde. | |
| 75 | + * | |
| 76 | + * @param modeServeur true = transcription différée côté serveur ; false = Vosk local. | |
| 77 | + * @param voskDisponible false si le modèle est absent (build -Pvosk=none). | |
| 78 | + * @param onSauvegarder appelé avec (sujet, texte, audioPath?) quand l'utilisateur valide. | |
| 79 | + * @param onDismiss appelé sur annulation / fermeture. | |
| 80 | + */ | |
| 81 | +@OptIn(ExperimentalMaterial3Api::class) | |
| 82 | +@Composable | |
| 83 | +fun EnregistrementNoteSheet( | |
| 84 | + modeServeur: Boolean, | |
| 85 | + voskDisponible: Boolean, | |
| 86 | + onSauvegarder: (sujet: String, texte: String, audioPath: String?) -> Unit, | |
| 87 | + onDismiss: () -> Unit, | |
| 88 | +) { | |
| 89 | + val context = LocalContext.current | |
| 90 | + val modeLocal = !modeServeur && voskDisponible | |
| 91 | + | |
| 92 | + val recorder = remember { AudioNoteRecorder() } | |
| 93 | + val transcripteur = remember { if (modeLocal) TranscripteurVosk(context.applicationContext) else null } | |
| 94 | + | |
| 95 | + var micAccorde by remember { | |
| 96 | + mutableStateOf( | |
| 97 | + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED, | |
| 98 | + ) | |
| 99 | + } | |
| 100 | + val permLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { | |
| 101 | + micAccorde = it | |
| 102 | + } | |
| 103 | + | |
| 104 | + var phase by remember { mutableStateOf(if (micAccorde) Phase.EN_COURS else Phase.EN_ATTENTE_PERMISSION) } | |
| 105 | + var dureeMs by remember { mutableStateOf(0L) } | |
| 106 | + var textePartiel by remember { mutableStateOf("") } | |
| 107 | + var texteTranscrit by remember { mutableStateOf("") } | |
| 108 | + var sujet by remember { mutableStateOf("") } | |
| 109 | + var audioPath by remember { mutableStateOf<String?>(null) } | |
| 110 | + | |
| 111 | + val scope = rememberCoroutineScope() | |
| 112 | + val formatSujetDefaut = stringResource(R.string.note_vocale_sujet_defaut) | |
| 113 | + var sauvegarde by remember { mutableStateOf(false) } | |
| 114 | + | |
| 115 | + // Demande la permission si absente | |
| 116 | + LaunchedEffect(Unit) { | |
| 117 | + if (!micAccorde) permLauncher.launch(Manifest.permission.RECORD_AUDIO) | |
| 118 | + } | |
| 119 | + | |
| 120 | + // Démarre l'enregistrement dès que la permission est accordée | |
| 121 | + LaunchedEffect(micAccorde) { | |
| 122 | + if (!micAccorde) return@LaunchedEffect | |
| 123 | + if (phase != Phase.EN_ATTENTE_PERMISSION && phase != Phase.EN_COURS) return@LaunchedEffect | |
| 124 | + phase = Phase.EN_COURS | |
| 125 | + val startMs = System.currentTimeMillis() | |
| 126 | + val file = withContext(Dispatchers.IO) { | |
| 127 | + val dir = File(context.filesDir, "notes_audio_temp").also { it.mkdirs() } | |
| 128 | + // Nettoyage des fichiers orphelins de plus de 24 h | |
| 129 | + val seuilMs = System.currentTimeMillis() - 24L * 60 * 60 * 1000 | |
| 130 | + dir.listFiles()?.filter { it.lastModified() < seuilMs }?.forEach { it.delete() } | |
| 131 | + File.createTempFile("note_vocale_", ".wav", dir) | |
| 132 | + } | |
| 133 | + audioPath = file.absolutePath | |
| 134 | + recorder.demarrer(file) { data, len -> | |
| 135 | + val partial = transcripteur?.accepterEchantillons(data, len) | |
| 136 | + if (partial != null) scope.launch(Dispatchers.Main.immediate) { textePartiel = partial } | |
| 137 | + } | |
| 138 | + while (phase == Phase.EN_COURS) { | |
| 139 | + dureeMs = System.currentTimeMillis() - startMs | |
| 140 | + delay(100L) | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + // Libère les ressources lors de la fermeture | |
| 145 | + DisposableEffect(Unit) { | |
| 146 | + onDispose { | |
| 147 | + if (phase == Phase.EN_COURS) recorder.arreter() | |
| 148 | + transcripteur?.reinitialiser() | |
| 149 | + // Supprime le fichier WAV temporaire si la note n'a pas été sauvegardée | |
| 150 | + if (!sauvegarde) audioPath?.let { File(it).delete() } | |
| 151 | + } | |
| 152 | + } | |
| 153 | + | |
| 154 | + ModalBottomSheet( | |
| 155 | + onDismissRequest = onDismiss, | |
| 156 | + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), | |
| 157 | + containerColor = Fond, | |
| 158 | + ) { | |
| 159 | + Column( | |
| 160 | + Modifier | |
| 161 | + .fillMaxWidth() | |
| 162 | + .padding(horizontal = 24.dp, vertical = 8.dp) | |
| 163 | + .padding(bottom = 32.dp), | |
| 164 | + verticalArrangement = Arrangement.spacedBy(12.dp), | |
| 165 | + ) { | |
| 166 | + // Avertissement permanent | |
| 167 | + Text( | |
| 168 | + stringResource(R.string.note_vocale_avertissement), | |
| 169 | + style = MaterialTheme.typography.bodySmall, | |
| 170 | + color = MaterialTheme.colorScheme.error, | |
| 171 | + ) | |
| 172 | + | |
| 173 | + when { | |
| 174 | + !micAccorde -> PermissionContent( | |
| 175 | + onDemander = { permLauncher.launch(Manifest.permission.RECORD_AUDIO) }, | |
| 176 | + ) | |
| 177 | + phase == Phase.EN_COURS -> EnCoursContent( | |
| 178 | + dureeMs = dureeMs, | |
| 179 | + textePartiel = textePartiel, | |
| 180 | + modeLocal = modeLocal, | |
| 181 | + onStop = { | |
| 182 | + scope.launch { | |
| 183 | + phase = Phase.ARRET | |
| 184 | + val resultat = withContext(Dispatchers.IO) { recorder.arreter() } | |
| 185 | + val texteFinal = if (modeLocal) { | |
| 186 | + withContext(Dispatchers.IO) { transcripteur?.finaliser().orEmpty() } | |
| 187 | + } else { | |
| 188 | + "" | |
| 189 | + } | |
| 190 | + texteTranscrit = texteFinal.ifBlank { textePartiel } | |
| 191 | + audioPath = resultat?.chemin | |
| 192 | + sujet = genererSujetParDefaut(formatSujetDefaut) | |
| 193 | + phase = Phase.EDITION | |
| 194 | + } | |
| 195 | + }, | |
| 196 | + ) | |
| 197 | + phase == Phase.ARRET -> Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { | |
| 198 | + CircularProgressIndicator(color = MaterialTheme.colorScheme.secondary) | |
| 199 | + } | |
| 200 | + phase == Phase.EDITION -> EditionContent( | |
| 201 | + sujet = sujet, | |
| 202 | + texteTranscrit = texteTranscrit, | |
| 203 | + modeServeur = modeServeur, | |
| 204 | + onSujetChange = { sujet = it }, | |
| 205 | + onTexteChange = { texteTranscrit = it }, | |
| 206 | + onSauvegarder = { | |
| 207 | + onSauvegarder(sujet.trim(), texteTranscrit.trim(), audioPath) | |
| 208 | + sauvegarde = true | |
| 209 | + onDismiss() | |
| 210 | + }, | |
| 211 | + onAnnuler = onDismiss, | |
| 212 | + ) | |
| 213 | + else -> Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { | |
| 214 | + CircularProgressIndicator(color = MaterialTheme.colorScheme.secondary) | |
| 215 | + } | |
| 216 | + } | |
| 217 | + } | |
| 218 | + } | |
| 219 | +} | |
| 220 | + | |
| 221 | +@Composable | |
| 222 | +private fun PermissionContent(onDemander: () -> Unit) { | |
| 223 | + Column( | |
| 224 | + Modifier.fillMaxWidth(), | |
| 225 | + horizontalAlignment = Alignment.CenterHorizontally, | |
| 226 | + verticalArrangement = Arrangement.spacedBy(12.dp), | |
| 227 | + ) { | |
| 228 | + Text( | |
| 229 | + stringResource(R.string.note_vocale_permission_manquante), | |
| 230 | + color = TexteFaible, | |
| 231 | + style = MaterialTheme.typography.bodyMedium, | |
| 232 | + ) | |
| 233 | + OutlinedButton(onClick = onDemander) { | |
| 234 | + Text(stringResource(R.string.note_vocale_autoriser_micro)) | |
| 235 | + } | |
| 236 | + } | |
| 237 | +} | |
| 238 | + | |
| 239 | +@Composable | |
| 240 | +private fun EnCoursContent( | |
| 241 | + dureeMs: Long, | |
| 242 | + textePartiel: String, | |
| 243 | + modeLocal: Boolean, | |
| 244 | + onStop: () -> Unit, | |
| 245 | +) { | |
| 246 | + Column( | |
| 247 | + Modifier.fillMaxWidth(), | |
| 248 | + horizontalAlignment = Alignment.CenterHorizontally, | |
| 249 | + verticalArrangement = Arrangement.spacedBy(16.dp), | |
| 250 | + ) { | |
| 251 | + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { | |
| 252 | + Icon(Icons.Filled.Mic, contentDescription = null, tint = MaterialTheme.colorScheme.error) | |
| 253 | + Text( | |
| 254 | + formaterDuree(dureeMs), | |
| 255 | + style = MaterialTheme.typography.titleLarge, | |
| 256 | + color = Ink, | |
| 257 | + ) | |
| 258 | + } | |
| 259 | + if (modeLocal && textePartiel.isNotBlank()) { | |
| 260 | + Text( | |
| 261 | + textePartiel, | |
| 262 | + style = MaterialTheme.typography.bodyMedium, | |
| 263 | + color = TexteFaible, | |
| 264 | + ) | |
| 265 | + } else if (!modeLocal) { | |
| 266 | + Text( | |
| 267 | + stringResource(R.string.note_vocale_transcription_serveur), | |
| 268 | + style = MaterialTheme.typography.bodySmall, | |
| 269 | + color = TexteFaible, | |
| 270 | + ) | |
| 271 | + } else { | |
| 272 | + Text( | |
| 273 | + stringResource(R.string.note_vocale_transcription_en_cours), | |
| 274 | + style = MaterialTheme.typography.bodySmall, | |
| 275 | + color = TexteFaible, | |
| 276 | + ) | |
| 277 | + } | |
| 278 | + Button(onClick = onStop, modifier = Modifier.fillMaxWidth()) { | |
| 279 | + Icon(Icons.Filled.Stop, contentDescription = null) | |
| 280 | + Spacer(Modifier.size(8.dp)) | |
| 281 | + Text(stringResource(R.string.note_vocale_stop)) | |
| 282 | + } | |
| 283 | + } | |
| 284 | +} | |
| 285 | + | |
| 286 | +@Composable | |
| 287 | +private fun EditionContent( | |
| 288 | + sujet: String, | |
| 289 | + texteTranscrit: String, | |
| 290 | + modeServeur: Boolean, | |
| 291 | + onSujetChange: (String) -> Unit, | |
| 292 | + onTexteChange: (String) -> Unit, | |
| 293 | + onSauvegarder: () -> Unit, | |
| 294 | + onAnnuler: () -> Unit, | |
| 295 | +) { | |
| 296 | + Column( | |
| 297 | + Modifier.fillMaxWidth(), | |
| 298 | + verticalArrangement = Arrangement.spacedBy(12.dp), | |
| 299 | + ) { | |
| 300 | + ChampTexte( | |
| 301 | + value = sujet, | |
| 302 | + onValueChange = onSujetChange, | |
| 303 | + label = stringResource(R.string.note_vocale_sujet_label), | |
| 304 | + ) | |
| 305 | + if (sujet.isBlank()) { | |
| 306 | + Text( | |
| 307 | + stringResource(R.string.note_vocale_sujet_aide), | |
| 308 | + style = MaterialTheme.typography.bodySmall, | |
| 309 | + color = MaterialTheme.colorScheme.error, | |
| 310 | + ) | |
| 311 | + } | |
| 312 | + if (modeServeur) { | |
| 313 | + Text( | |
| 314 | + stringResource(R.string.note_vocale_transcription_serveur), | |
| 315 | + style = MaterialTheme.typography.bodySmall, | |
| 316 | + color = TexteFaible, | |
| 317 | + ) | |
| 318 | + } else { | |
| 319 | + ChampTexte( | |
| 320 | + value = texteTranscrit, | |
| 321 | + onValueChange = onTexteChange, | |
| 322 | + label = stringResource(R.string.note_vocale_texte_label), | |
| 323 | + singleLine = false, | |
| 324 | + minLines = 3, | |
| 325 | + ) | |
| 326 | + } | |
| 327 | + Row( | |
| 328 | + Modifier.fillMaxWidth(), | |
| 329 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 330 | + ) { | |
| 331 | + OutlinedButton(onClick = onAnnuler, modifier = Modifier.weight(1f)) { | |
| 332 | + Text(stringResource(R.string.note_vocale_annuler)) | |
| 333 | + } | |
| 334 | + Button( | |
| 335 | + onClick = onSauvegarder, | |
| 336 | + enabled = sujet.isNotBlank(), | |
| 337 | + modifier = Modifier.weight(1f), | |
| 338 | + ) { | |
| 339 | + Text(stringResource(R.string.note_vocale_sauvegarder)) | |
| 340 | + } | |
| 341 | + } | |
| 342 | + } | |
| 343 | +} | |
| 344 | + | |
| 345 | +// --------------------------------------------------------------------------- | |
| 346 | +// Lecteur audio | |
| 347 | +// --------------------------------------------------------------------------- | |
| 348 | + | |
| 349 | +/** État d'un lecteur audio. */ | |
| 350 | +enum class EtatLecture { ARRET, CHARGEMENT, LECTURE, ERREUR } | |
| 351 | + | |
| 352 | +/** | |
| 353 | + * Lecteur audio minimal : bouton play/pause si [audioPath] est disponible localement, | |
| 354 | + * bouton téléchargement sinon (si [onTelechargement] non null). | |
| 355 | + * Gère le cycle de vie [MediaPlayer] via [DisposableEffect]. | |
| 356 | + */ | |
| 357 | +@Composable | |
| 358 | +fun LecteurAudio( | |
| 359 | + audioPath: String?, | |
| 360 | + enChargement: Boolean, | |
| 361 | + onTelechargement: (() -> Unit)?, | |
| 362 | + modifier: Modifier = Modifier, | |
| 363 | +) { | |
| 364 | + var etat by remember(audioPath) { mutableStateOf(EtatLecture.ARRET) } | |
| 365 | + val player = remember { MediaPlayer() } | |
| 366 | + val scope = rememberCoroutineScope() | |
| 367 | + | |
| 368 | + // Libère définitivement les ressources natives quand le composable quitte la composition. | |
| 369 | + // Déclaré avant DisposableEffect(audioPath) : l'ordre LIFO garantit reset() avant release(). | |
| 370 | + DisposableEffect(Unit) { | |
| 371 | + onDispose { player.release() } | |
| 372 | + } | |
| 373 | + DisposableEffect(audioPath) { | |
| 374 | + onDispose { | |
| 375 | + if (player.isPlaying) player.stop() | |
| 376 | + player.reset() | |
| 377 | + } | |
| 378 | + } | |
| 379 | + | |
| 380 | + Row(modifier, verticalAlignment = Alignment.CenterVertically) { | |
| 381 | + when { | |
| 382 | + enChargement -> CircularProgressIndicator(Modifier.size(24.dp), color = MaterialTheme.colorScheme.secondary) | |
| 383 | + audioPath != null -> { | |
| 384 | + IconButton( | |
| 385 | + onClick = { | |
| 386 | + scope.launch { | |
| 387 | + when (etat) { | |
| 388 | + EtatLecture.ARRET, EtatLecture.ERREUR -> { | |
| 389 | + runCatching { | |
| 390 | + player.reset() | |
| 391 | + player.setDataSource(audioPath) | |
| 392 | + withContext(Dispatchers.IO) { player.prepare() } | |
| 393 | + player.start() | |
| 394 | + etat = EtatLecture.LECTURE | |
| 395 | + player.setOnCompletionListener { etat = EtatLecture.ARRET } | |
| 396 | + }.onFailure { etat = EtatLecture.ERREUR } | |
| 397 | + } | |
| 398 | + EtatLecture.LECTURE -> { | |
| 399 | + player.pause() | |
| 400 | + etat = EtatLecture.ARRET | |
| 401 | + } | |
| 402 | + EtatLecture.CHARGEMENT -> Unit | |
| 403 | + } | |
| 404 | + } | |
| 405 | + }, | |
| 406 | + ) { | |
| 407 | + Icon( | |
| 408 | + if (etat == EtatLecture.LECTURE) Icons.Filled.Pause else Icons.Filled.PlayArrow, | |
| 409 | + contentDescription = if (etat == EtatLecture.LECTURE) | |
| 410 | + stringResource(R.string.lecteur_audio_pause) | |
| 411 | + else | |
| 412 | + stringResource(R.string.lecteur_audio_lire), | |
| 413 | + tint = Ink, | |
| 414 | + ) | |
| 415 | + } | |
| 416 | + if (etat == EtatLecture.ERREUR) { | |
| 417 | + Text( | |
| 418 | + stringResource(R.string.lecteur_audio_erreur), | |
| 419 | + color = MaterialTheme.colorScheme.error, | |
| 420 | + style = MaterialTheme.typography.bodySmall, | |
| 421 | + ) | |
| 422 | + } | |
| 423 | + } | |
| 424 | + onTelechargement != null -> { | |
| 425 | + OutlinedButton(onClick = onTelechargement) { | |
| 426 | + Text(stringResource(R.string.lecteur_audio_telecharger), style = MaterialTheme.typography.bodySmall) | |
| 427 | + } | |
| 428 | + } | |
| 429 | + } | |
| 430 | + } | |
| 431 | +} |
A
android/app/src/main/java/fr/ebii/card2vcf/ui/audio/NoteVocaleUtil.kt
+25
-0
@@ -0,0 +1,25 @@
| 1 | +package fr.ebii.card2vcf.ui.audio | |
| 2 | + | |
| 3 | +import java.util.Calendar | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Génère un sujet par défaut « Notes du jj/MM/yyyy à HH:mm ». | |
| 7 | + * | |
| 8 | + * [formatModele] doit contenir un placeholder %1$s pour la date formatée | |
| 9 | + * (ex. "Notes du %1$s" depuis strings.xml). | |
| 10 | + * [maintenant] est injectable pour les tests (millisecondes depuis l'epoch). | |
| 11 | + */ | |
| 12 | +fun genererSujetParDefaut( | |
| 13 | + formatModele: String, | |
| 14 | + maintenant: Long = System.currentTimeMillis(), | |
| 15 | +): String { | |
| 16 | + val cal = Calendar.getInstance().also { it.timeInMillis = maintenant } | |
| 17 | + val dateFormatee = "%02d/%02d/%04d à %02d:%02d".format( | |
| 18 | + cal.get(Calendar.DAY_OF_MONTH), | |
| 19 | + cal.get(Calendar.MONTH) + 1, | |
| 20 | + cal.get(Calendar.YEAR), | |
| 21 | + cal.get(Calendar.HOUR_OF_DAY), | |
| 22 | + cal.get(Calendar.MINUTE), | |
| 23 | + ) | |
| 24 | + return String.format(formatModele, dateFormatee) | |
| 25 | +} |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/composants/Composants.kt
+30
-0
@@ -27,7 +27,9 @@ import androidx.compose.runtime.remember
| 27 | 27 | import androidx.compose.runtime.setValue |
| 28 | 28 | import androidx.compose.ui.Alignment |
| 29 | 29 | import androidx.compose.ui.Modifier |
| 30 | +import androidx.compose.ui.res.stringResource | |
| 30 | 31 | import androidx.compose.ui.unit.dp |
| 32 | +import fr.ebii.card2vcf.R | |
| 31 | 33 | import fr.ebii.card2vcf.data.CrmContactEntity |
| 32 | 34 | |
| 33 | 35 | /** Barre de titre commune : retour + titre + actions à droite. */ |
@@ -171,3 +173,31 @@ fun nomAffiche(contact: CrmContactEntity): String {
| 171 | 173 | .joinToString(" ") |
| 172 | 174 | return compose.ifEmpty { contact.company?.trim().orEmpty().ifEmpty { "Sans nom" } } |
| 173 | 175 | } |
| 176 | + | |
| 177 | +/** | |
| 178 | + * Badge de statut de transcription (en_attente / echec) — commun aux notes contact et projet. | |
| 179 | + * Si [statut] == "echec" et [erreur] est fourni, affiche le message d'erreur en dessous du badge. | |
| 180 | + */ | |
| 181 | +@Composable | |
| 182 | +fun BadgeStatutTranscription(statut: String?, erreur: String? = null) { | |
| 183 | + val label = when (statut) { | |
| 184 | + "en_attente" -> stringResource(R.string.note_vocale_badge_en_attente) | |
| 185 | + "echec" -> stringResource(R.string.note_vocale_badge_echec) | |
| 186 | + else -> return | |
| 187 | + } | |
| 188 | + val color = if (statut == "echec") MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.secondary | |
| 189 | + Column { | |
| 190 | + Text( | |
| 191 | + label, | |
| 192 | + style = MaterialTheme.typography.labelSmall, | |
| 193 | + color = color, | |
| 194 | + ) | |
| 195 | + if (statut == "echec" && !erreur.isNullOrBlank()) { | |
| 196 | + Text( | |
| 197 | + erreur, | |
| 198 | + style = MaterialTheme.typography.bodySmall, | |
| 199 | + color = color, | |
| 200 | + ) | |
| 201 | + } | |
| 202 | + } | |
| 203 | +} |
A
android/app/src/main/java/fr/ebii/card2vcf/ui/composants/MarkdownText.kt
+48
-0
@@ -0,0 +1,48 @@
| 1 | +package fr.ebii.card2vcf.ui.composants | |
| 2 | + | |
| 3 | +import android.text.method.LinkMovementMethod | |
| 4 | +import android.widget.TextView | |
| 5 | +import androidx.compose.material3.LocalContentColor | |
| 6 | +import androidx.compose.material3.LocalTextStyle | |
| 7 | +import androidx.compose.runtime.Composable | |
| 8 | +import androidx.compose.runtime.remember | |
| 9 | +import androidx.compose.ui.Modifier | |
| 10 | +import androidx.compose.ui.graphics.toArgb | |
| 11 | +import androidx.compose.ui.platform.LocalContext | |
| 12 | +import androidx.compose.ui.unit.isSpecified | |
| 13 | +import io.noties.markwon.Markwon | |
| 14 | + | |
| 15 | +/** Rendu markdown via Markwon dans un TextView Android. | |
| 16 | + * Couleur et taille de texte héritées de [LocalContentColor] / [LocalTextStyle]. | |
| 17 | + * Les liens sont cliquables. | |
| 18 | + */ | |
| 19 | +@Composable | |
| 20 | +fun MarkdownText( | |
| 21 | + markdown: String, | |
| 22 | + modifier: Modifier = Modifier, | |
| 23 | +) { | |
| 24 | + val context = LocalContext.current | |
| 25 | + val contentColor = LocalContentColor.current | |
| 26 | + val textStyle = LocalTextStyle.current | |
| 27 | + | |
| 28 | + val markwon = remember(context) { Markwon.create(context) } | |
| 29 | + | |
| 30 | + val textColor = contentColor.toArgb() | |
| 31 | + val fontSizeSp = textStyle.fontSize | |
| 32 | + | |
| 33 | + androidx.compose.ui.viewinterop.AndroidView( | |
| 34 | + factory = { ctx -> | |
| 35 | + TextView(ctx).apply { | |
| 36 | + movementMethod = LinkMovementMethod.getInstance() | |
| 37 | + } | |
| 38 | + }, | |
| 39 | + update = { tv -> | |
| 40 | + tv.setTextColor(textColor) | |
| 41 | + if (fontSizeSp.isSpecified && fontSizeSp.value > 0f) { | |
| 42 | + tv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, fontSizeSp.value) | |
| 43 | + } | |
| 44 | + markwon.setMarkdown(tv, markdown) | |
| 45 | + }, | |
| 46 | + modifier = modifier, | |
| 47 | + ) | |
| 48 | +} |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/contact/ContactPagerScreen.kt
+235
-44
@@ -3,6 +3,7 @@ package fr.ebii.card2vcf.ui.contact
| 3 | 3 | import android.content.ActivityNotFoundException |
| 4 | 4 | import android.content.Intent |
| 5 | 5 | import android.net.Uri |
| 6 | +import android.widget.Toast | |
| 6 | 7 | import java.io.File |
| 7 | 8 | import androidx.compose.foundation.Image |
| 8 | 9 | import androidx.compose.foundation.background |
@@ -23,13 +24,15 @@ import androidx.compose.foundation.layout.size
| 23 | 24 | import androidx.compose.foundation.pager.HorizontalPager |
| 24 | 25 | import androidx.compose.foundation.pager.rememberPagerState |
| 25 | 26 | import androidx.compose.foundation.rememberScrollState |
| 26 | -import androidx.compose.foundation.shape.RoundedCornerShape | |
| 27 | 27 | import androidx.compose.foundation.verticalScroll |
| 28 | 28 | import androidx.compose.material.icons.Icons |
| 29 | 29 | import androidx.compose.material.icons.automirrored.outlined.ArrowBack |
| 30 | 30 | import androidx.compose.material.icons.automirrored.outlined.RotateLeft |
| 31 | 31 | import androidx.compose.material.icons.filled.Call |
| 32 | +import androidx.compose.material.icons.filled.Delete | |
| 32 | 33 | import androidx.compose.material.icons.filled.Email |
| 34 | +import androidx.compose.material.icons.filled.Mic | |
| 35 | +import androidx.compose.material3.AlertDialog | |
| 33 | 36 | import androidx.compose.material3.HorizontalDivider |
| 34 | 37 | import androidx.compose.material3.Icon |
| 35 | 38 | import androidx.compose.material3.IconButton |
@@ -38,10 +41,14 @@ import androidx.compose.material3.OutlinedButton
| 38 | 41 | import androidx.compose.material3.Text |
| 39 | 42 | import androidx.compose.material3.TextButton |
| 40 | 43 | import androidx.compose.runtime.Composable |
| 44 | +import androidx.compose.runtime.LaunchedEffect | |
| 41 | 45 | import androidx.compose.runtime.collectAsState |
| 42 | 46 | import androidx.compose.runtime.getValue |
| 47 | +import androidx.compose.runtime.mutableStateOf | |
| 43 | 48 | import androidx.compose.runtime.remember |
| 49 | +import androidx.compose.runtime.setValue | |
| 44 | 50 | import androidx.compose.ui.Alignment |
| 51 | +import androidx.compose.ui.platform.LocalContext | |
| 45 | 52 | import androidx.compose.ui.Modifier |
| 46 | 53 | import androidx.compose.ui.graphics.ImageBitmap |
| 47 | 54 | import androidx.compose.ui.graphics.asImageBitmap |
@@ -51,14 +58,21 @@ import androidx.compose.ui.res.stringResource
| 51 | 58 | import androidx.compose.ui.unit.dp |
| 52 | 59 | import fr.ebii.card2vcf.R |
| 53 | 60 | import fr.ebii.card2vcf.data.CrmContactEntity |
| 61 | +import fr.ebii.card2vcf.data.InteractionEntity | |
| 54 | 62 | import fr.ebii.card2vcf.scan.ImageOrientation |
| 63 | +import fr.ebii.card2vcf.ui.audio.ConfirmationNote | |
| 64 | +import fr.ebii.card2vcf.ui.audio.EnregistrementNoteSheet | |
| 65 | +import fr.ebii.card2vcf.ui.audio.LecteurAudio | |
| 66 | +import fr.ebii.card2vcf.ui.composants.BadgeStatutTranscription | |
| 67 | +import fr.ebii.card2vcf.ui.composants.EtatVide | |
| 68 | +import fr.ebii.card2vcf.ui.composants.nomAffiche | |
| 55 | 69 | import fr.ebii.card2vcf.ui.theme.Bordure |
| 56 | 70 | import fr.ebii.card2vcf.ui.theme.Encre |
| 57 | 71 | import fr.ebii.card2vcf.ui.theme.Fond |
| 58 | 72 | import fr.ebii.card2vcf.ui.theme.Ink |
| 59 | -import fr.ebii.card2vcf.ui.theme.Link | |
| 60 | 73 | import fr.ebii.card2vcf.ui.theme.Surface |
| 61 | 74 | import fr.ebii.card2vcf.ui.theme.TexteFaible |
| 75 | +import kotlinx.coroutines.flow.flowOf | |
| 62 | 76 | |
| 63 | 77 | |
| 64 | 78 | @Composable |
@@ -67,60 +81,103 @@ fun ContactPagerScreen(
| 67 | 81 | onBack: () -> Unit, |
| 68 | 82 | onEdit: (contactId: Long) -> Unit, |
| 69 | 83 | onDuplicates: (contactId: Long) -> Unit, |
| 84 | + modeServeur: Boolean, | |
| 85 | + voskDisponible: Boolean, | |
| 70 | 86 | modifier: Modifier = Modifier, |
| 71 | 87 | ) { |
| 72 | 88 | val contacts by viewModel.contacts.collectAsState() |
| 73 | 89 | val duplicateCountById by viewModel.duplicateCountById.collectAsState() |
| 90 | + val downloadEnCours by viewModel.downloadEnCours.collectAsState() | |
| 91 | + val confirmationNote by viewModel.confirmationNote.collectAsState() | |
| 92 | + val messageErreur by viewModel.messageErreur.collectAsState() | |
| 93 | + var sheetVisible by remember { mutableStateOf(false) } | |
| 94 | + | |
| 95 | + val context = LocalContext.current | |
| 96 | + LaunchedEffect(confirmationNote) { | |
| 97 | + val confirmation = confirmationNote ?: return@LaunchedEffect | |
| 98 | + val msgId = when (confirmation) { | |
| 99 | + ConfirmationNote.APPAREIL -> R.string.note_vocale_confirmation_appareil | |
| 100 | + ConfirmationNote.SERVEUR_OK -> R.string.note_vocale_confirmation_serveur_envoyee | |
| 101 | + ConfirmationNote.SERVEUR_REPLI -> R.string.note_vocale_confirmation_serveur_repli | |
| 102 | + } | |
| 103 | + Toast.makeText(context, msgId, Toast.LENGTH_LONG).show() | |
| 104 | + viewModel.consommerConfirmation() | |
| 105 | + } | |
| 106 | + LaunchedEffect(messageErreur) { | |
| 107 | + if (messageErreur == null) return@LaunchedEffect | |
| 108 | + Toast.makeText(context, R.string.note_vocale_relance_impossible, Toast.LENGTH_LONG).show() | |
| 109 | + viewModel.consommerMessageErreur() | |
| 110 | + } | |
| 111 | + | |
| 112 | + // rememberPagerState doit être appelé inconditionnellement (règle Compose). | |
| 113 | + val initialPage = if (contacts.isNotEmpty()) viewModel.initialPageIndex(contacts) else 0 | |
| 114 | + val pagerState = rememberPagerState( | |
| 115 | + initialPage = initialPage, | |
| 116 | + pageCount = { contacts.size }, | |
| 117 | + ) | |
| 118 | + val current: CrmContactEntity? = contacts.getOrNull(pagerState.currentPage) | |
| 119 | + val dupCount = current?.let { duplicateCountById[it.id] } ?: 0 | |
| 74 | 120 | |
| 75 | 121 | Column( |
| 76 | 122 | modifier |
| 77 | 123 | .fillMaxSize() |
| 78 | 124 | .background(Fond), |
| 79 | 125 | ) { |
| 126 | + FicheTopBar( | |
| 127 | + onBack = onBack, | |
| 128 | + onEdit = current?.let { c -> { onEdit(c.id) } }, | |
| 129 | + duplicateCount = dupCount, | |
| 130 | + onDuplicates = if (dupCount > 0 && current != null) { | |
| 131 | + { onDuplicates(current.id) } | |
| 132 | + } else { | |
| 133 | + null | |
| 134 | + }, | |
| 135 | + ) | |
| 136 | + | |
| 80 | 137 | if (contacts.isEmpty()) { |
| 81 | - FicheTopBar( | |
| 82 | - onBack = onBack, | |
| 83 | - onEdit = null, | |
| 84 | - duplicateCount = 0, | |
| 85 | - onDuplicates = null, | |
| 86 | - ) | |
| 87 | 138 | Box( |
| 88 | 139 | Modifier.fillMaxSize(), |
| 89 | 140 | contentAlignment = Alignment.Center, |
| 90 | 141 | ) { |
| 91 | 142 | Text(stringResource(R.string.carnet_vide), color = TexteFaible) |
| 92 | 143 | } |
| 93 | - return@Column | |
| 94 | - } | |
| 144 | + } else { | |
| 145 | + HorizontalPager( | |
| 146 | + state = pagerState, | |
| 147 | + modifier = Modifier.fillMaxSize(), | |
| 148 | + ) { page -> | |
| 149 | + val contact = contacts[page] | |
| 150 | + val serverId = contact.serverId | |
| 151 | + val notesVocales by remember(serverId) { | |
| 152 | + if (serverId != null) viewModel.observeNotesVocales(serverId) else flowOf(emptyList()) | |
| 153 | + }.collectAsState(initial = emptyList()) | |
| 95 | 154 | |
| 96 | - val initialPage = viewModel.initialPageIndex(contacts) | |
| 97 | - val pagerState = rememberPagerState( | |
| 98 | - initialPage = initialPage, | |
| 99 | - pageCount = { contacts.size }, | |
| 100 | - ) | |
| 101 | - val current = contacts.getOrNull(pagerState.currentPage) ?: contacts[initialPage] | |
| 102 | - val dupCount = duplicateCountById[current.id] ?: 0 | |
| 155 | + ContactFichePage( | |
| 156 | + contact = contact, | |
| 157 | + notesVocales = notesVocales, | |
| 158 | + downloadEnCours = downloadEnCours, | |
| 159 | + onRotateCardLeft = { viewModel.rotateCardLeft(contacts[page].id) }, | |
| 160 | + onAjouterNote = { sheetVisible = true }, | |
| 161 | + onTelechargement = viewModel::telechargerAudioInteraction, | |
| 162 | + onSupprimer = viewModel::supprimerNoteVocale, | |
| 163 | + onRelancer = viewModel::relancerTranscriptionInteraction, | |
| 164 | + ) | |
| 165 | + } | |
| 166 | + } | |
| 167 | + } | |
| 103 | 168 | |
| 104 | - FicheTopBar( | |
| 105 | - onBack = onBack, | |
| 106 | - onEdit = { onEdit(current.id) }, | |
| 107 | - duplicateCount = dupCount, | |
| 108 | - onDuplicates = if (dupCount > 0) { | |
| 109 | - { onDuplicates(current.id) } | |
| 110 | - } else { | |
| 111 | - null | |
| 169 | + // Feuille d'enregistrement — affichée au-dessus du pager. | |
| 170 | + // Le bouton micro n'est visible que si serverId != null, donc ce cas ne survient pas normalement. | |
| 171 | + val currentServerId = current?.serverId | |
| 172 | + if (sheetVisible && currentServerId != null) { | |
| 173 | + EnregistrementNoteSheet( | |
| 174 | + modeServeur = modeServeur, | |
| 175 | + voskDisponible = voskDisponible, | |
| 176 | + onSauvegarder = { sujet, texte, audioPath -> | |
| 177 | + viewModel.ajouterNoteVocale(currentServerId, sujet, texte, audioPath, modeServeur) | |
| 112 | 178 | }, |
| 179 | + onDismiss = { sheetVisible = false }, | |
| 113 | 180 | ) |
| 114 | - | |
| 115 | - HorizontalPager( | |
| 116 | - state = pagerState, | |
| 117 | - modifier = Modifier.fillMaxSize(), | |
| 118 | - ) { page -> | |
| 119 | - ContactFichePage( | |
| 120 | - contact = contacts[page], | |
| 121 | - onRotateCardLeft = { viewModel.rotateCardLeft(contacts[page].id) }, | |
| 122 | - ) | |
| 123 | - } | |
| 124 | 181 | } |
| 125 | 182 | } |
| 126 | 183 |
@@ -162,7 +219,13 @@ private fun FicheTopBar(
| 162 | 219 | @Composable |
| 163 | 220 | private fun ContactFichePage( |
| 164 | 221 | contact: CrmContactEntity, |
| 222 | + notesVocales: List<InteractionEntity>, | |
| 223 | + downloadEnCours: Set<Long>, | |
| 165 | 224 | onRotateCardLeft: () -> Unit, |
| 225 | + onAjouterNote: () -> Unit, | |
| 226 | + onTelechargement: (InteractionEntity) -> Unit, | |
| 227 | + onSupprimer: (InteractionEntity) -> Unit, | |
| 228 | + onRelancer: (InteractionEntity) -> Unit, | |
| 166 | 229 | modifier: Modifier = Modifier, |
| 167 | 230 | ) { |
| 168 | 231 | val context = LocalContext.current |
@@ -206,7 +269,7 @@ private fun ContactFichePage(
| 206 | 269 | ) |
| 207 | 270 | |
| 208 | 271 | Text( |
| 209 | - text = contactDisplayName(contact), | |
| 272 | + text = nomAffiche(contact), | |
| 210 | 273 | style = MaterialTheme.typography.titleLarge, |
| 211 | 274 | color = Encre, |
| 212 | 275 | ) |
@@ -260,6 +323,142 @@ private fun ContactFichePage(
| 260 | 323 | HorizontalDivider(color = Bordure, thickness = 1.dp) |
| 261 | 324 | FieldBlock(stringResource(R.string.scan_champ_note), contact.notes) |
| 262 | 325 | } |
| 326 | + | |
| 327 | + // Section notes vocales — uniquement si le contact est synchronisé (serverId connu) | |
| 328 | + if (contact.serverId != null) { | |
| 329 | + HorizontalDivider(color = Bordure, thickness = 1.dp) | |
| 330 | + NotesVocalesSection( | |
| 331 | + notes = notesVocales, | |
| 332 | + downloadEnCours = downloadEnCours, | |
| 333 | + onAjouterNote = onAjouterNote, | |
| 334 | + onTelechargement = onTelechargement, | |
| 335 | + onSupprimer = onSupprimer, | |
| 336 | + onRelancer = onRelancer, | |
| 337 | + ) | |
| 338 | + } | |
| 339 | + } | |
| 340 | +} | |
| 341 | + | |
| 342 | +@Composable | |
| 343 | +private fun NotesVocalesSection( | |
| 344 | + notes: List<InteractionEntity>, | |
| 345 | + downloadEnCours: Set<Long>, | |
| 346 | + onAjouterNote: () -> Unit, | |
| 347 | + onTelechargement: (InteractionEntity) -> Unit, | |
| 348 | + onSupprimer: (InteractionEntity) -> Unit, | |
| 349 | + onRelancer: (InteractionEntity) -> Unit, | |
| 350 | +) { | |
| 351 | + var pendingDelete by remember { mutableStateOf<InteractionEntity?>(null) } | |
| 352 | + | |
| 353 | + Row( | |
| 354 | + Modifier.fillMaxWidth(), | |
| 355 | + verticalAlignment = Alignment.CenterVertically, | |
| 356 | + horizontalArrangement = Arrangement.SpaceBetween, | |
| 357 | + ) { | |
| 358 | + Text( | |
| 359 | + stringResource(R.string.note_vocale_titre_section), | |
| 360 | + style = MaterialTheme.typography.labelMedium, | |
| 361 | + color = TexteFaible, | |
| 362 | + ) | |
| 363 | + IconButton(onClick = onAjouterNote) { | |
| 364 | + Icon( | |
| 365 | + Icons.Filled.Mic, | |
| 366 | + contentDescription = stringResource(R.string.note_vocale_enregistrer), | |
| 367 | + tint = Ink, | |
| 368 | + ) | |
| 369 | + } | |
| 370 | + } | |
| 371 | + if (notes.isEmpty()) { | |
| 372 | + EtatVide( | |
| 373 | + texte = stringResource(R.string.note_vocale_vide), | |
| 374 | + modifier = Modifier | |
| 375 | + .fillMaxWidth() | |
| 376 | + .height(60.dp), | |
| 377 | + ) | |
| 378 | + } else { | |
| 379 | + notes.forEach { note -> | |
| 380 | + NoteVocaleRow( | |
| 381 | + note = note, | |
| 382 | + enChargement = downloadEnCours.contains(note.localId), | |
| 383 | + onTelechargement = { onTelechargement(note) }, | |
| 384 | + onSupprimer = { pendingDelete = note }, | |
| 385 | + onRelancer = if (note.transcriptionStatut == "echec") { | |
| 386 | + { onRelancer(note) } | |
| 387 | + } else { | |
| 388 | + null | |
| 389 | + }, | |
| 390 | + ) | |
| 391 | + HorizontalDivider(color = Bordure, thickness = 1.dp) | |
| 392 | + } | |
| 393 | + } | |
| 394 | + | |
| 395 | + if (pendingDelete != null) { | |
| 396 | + AlertDialog( | |
| 397 | + onDismissRequest = { pendingDelete = null }, | |
| 398 | + title = { Text(stringResource(R.string.note_vocale_supprimer_titre), color = Ink) }, | |
| 399 | + text = { Text(stringResource(R.string.note_vocale_supprimer_message), color = Ink) }, | |
| 400 | + confirmButton = { | |
| 401 | + TextButton(onClick = { | |
| 402 | + pendingDelete?.let { onSupprimer(it) } | |
| 403 | + pendingDelete = null | |
| 404 | + }) { | |
| 405 | + Text(stringResource(R.string.note_vocale_supprimer_confirmer), color = MaterialTheme.colorScheme.error) | |
| 406 | + } | |
| 407 | + }, | |
| 408 | + dismissButton = { | |
| 409 | + TextButton(onClick = { pendingDelete = null }) { | |
| 410 | + Text(stringResource(R.string.note_vocale_supprimer_annuler), color = Ink) | |
| 411 | + } | |
| 412 | + }, | |
| 413 | + ) | |
| 414 | + } | |
| 415 | +} | |
| 416 | + | |
| 417 | +@Composable | |
| 418 | +private fun NoteVocaleRow( | |
| 419 | + note: InteractionEntity, | |
| 420 | + enChargement: Boolean, | |
| 421 | + onTelechargement: () -> Unit, | |
| 422 | + onSupprimer: () -> Unit, | |
| 423 | + onRelancer: (() -> Unit)?, | |
| 424 | +) { | |
| 425 | + Column( | |
| 426 | + Modifier | |
| 427 | + .fillMaxWidth() | |
| 428 | + .padding(vertical = 6.dp), | |
| 429 | + ) { | |
| 430 | + Row( | |
| 431 | + Modifier.fillMaxWidth(), | |
| 432 | + verticalAlignment = Alignment.CenterVertically, | |
| 433 | + ) { | |
| 434 | + Text( | |
| 435 | + note.sujet, | |
| 436 | + style = MaterialTheme.typography.bodyMedium, | |
| 437 | + color = Ink, | |
| 438 | + modifier = Modifier.weight(1f), | |
| 439 | + ) | |
| 440 | + IconButton(onClick = onSupprimer) { | |
| 441 | + Icon( | |
| 442 | + Icons.Filled.Delete, | |
| 443 | + contentDescription = stringResource(R.string.note_vocale_supprimer), | |
| 444 | + tint = TexteFaible, | |
| 445 | + ) | |
| 446 | + } | |
| 447 | + } | |
| 448 | + BadgeStatutTranscription(note.transcriptionStatut, note.transcriptionErreur) | |
| 449 | + if (note.description.isNotBlank()) { | |
| 450 | + Text(note.description, style = MaterialTheme.typography.bodySmall, color = TexteFaible) | |
| 451 | + } | |
| 452 | + if (onRelancer != null) { | |
| 453 | + TextButton(onClick = onRelancer) { | |
| 454 | + Text(stringResource(R.string.note_vocale_relancer), color = MaterialTheme.colorScheme.error) | |
| 455 | + } | |
| 456 | + } | |
| 457 | + LecteurAudio( | |
| 458 | + audioPath = note.audioPath, | |
| 459 | + enChargement = enChargement, | |
| 460 | + onTelechargement = if (note.audioPath == null && note.serverId != null) onTelechargement else null, | |
| 461 | + ) | |
| 263 | 462 | } |
| 264 | 463 | } |
| 265 | 464 |
@@ -328,11 +527,3 @@ private fun PathImage(
| 328 | 527 | ) |
| 329 | 528 | } |
| 330 | 529 | } |
| 331 | - | |
| 332 | -private fun contactDisplayName(c: CrmContactEntity): String { | |
| 333 | - c.fullName?.takeIf { it.isNotBlank() }?.let { return it } | |
| 334 | - val composed = listOfNotNull(c.firstName, c.lastName).joinToString(" ").trim() | |
| 335 | - if (composed.isNotEmpty()) return composed | |
| 336 | - c.company?.takeIf { it.isNotBlank() }?.let { return it } | |
| 337 | - return "—" | |
| 338 | -} |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/contact/ContactPagerViewModel.kt
+190
-0
@@ -5,21 +5,44 @@ import androidx.lifecycle.viewModelScope
| 5 | 5 | import fr.ebii.card2vcf.crm.AlphabetIndex |
| 6 | 6 | import fr.ebii.card2vcf.crm.ContactSort |
| 7 | 7 | import fr.ebii.card2vcf.crm.DuplicateDetector |
| 8 | +import fr.ebii.card2vcf.data.AudioNoteStore | |
| 8 | 9 | import fr.ebii.card2vcf.data.ContactRepository |
| 9 | 10 | import fr.ebii.card2vcf.data.CrmContactEntity |
| 11 | +import fr.ebii.card2vcf.data.CrmDatabase | |
| 12 | +import fr.ebii.card2vcf.data.InteractionEntity | |
| 13 | +import fr.ebii.card2vcf.sync.AilianceApi | |
| 14 | +import fr.ebii.card2vcf.sync.AilianceApiClient | |
| 15 | +import fr.ebii.card2vcf.sync.CreateInteractionRequest | |
| 16 | +import fr.ebii.card2vcf.sync.SyncCredentialsStore | |
| 17 | +import fr.ebii.card2vcf.sync.SyncEngine | |
| 18 | +import fr.ebii.card2vcf.sync.SyncOpEntity | |
| 19 | +import fr.ebii.card2vcf.sync.syncJson | |
| 20 | +import fr.ebii.card2vcf.ui.audio.ConfirmationNote | |
| 21 | +import kotlinx.coroutines.Dispatchers | |
| 10 | 22 | import kotlinx.coroutines.ExperimentalCoroutinesApi |
| 23 | +import java.io.File | |
| 24 | +import kotlinx.coroutines.flow.Flow | |
| 25 | +import kotlinx.coroutines.flow.MutableStateFlow | |
| 11 | 26 | import kotlinx.coroutines.flow.SharingStarted |
| 12 | 27 | import kotlinx.coroutines.flow.StateFlow |
| 28 | +import kotlinx.coroutines.flow.asStateFlow | |
| 13 | 29 | import kotlinx.coroutines.flow.map |
| 14 | 30 | import kotlinx.coroutines.flow.mapLatest |
| 15 | 31 | import kotlinx.coroutines.flow.stateIn |
| 32 | +import kotlinx.coroutines.flow.update | |
| 16 | 33 | import kotlinx.coroutines.launch |
| 34 | +import kotlinx.coroutines.withContext | |
| 35 | +import kotlinx.serialization.encodeToString | |
| 17 | 36 | |
| 18 | 37 | @OptIn(ExperimentalCoroutinesApi::class) |
| 19 | 38 | class ContactPagerViewModel( |
| 20 | 39 | private val repository: ContactRepository, |
| 40 | + private val database: CrmDatabase, | |
| 41 | + private val credentialsStore: SyncCredentialsStore, | |
| 42 | + private val audioStore: AudioNoteStore, | |
| 21 | 43 | val initialContactId: Long, |
| 22 | 44 | val sort: ContactSort, |
| 45 | + private val engineFabrique: () -> SyncEngine?, | |
| 23 | 46 | ) : ViewModel() { |
| 24 | 47 | |
| 25 | 48 | val contacts: StateFlow<List<CrmContactEntity>> = repository.observeAll() |
@@ -36,6 +59,25 @@ class ContactPagerViewModel(
| 36 | 59 | } |
| 37 | 60 | .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) |
| 38 | 61 | |
| 62 | + /** localId des interactions dont le téléchargement audio est en cours. */ | |
| 63 | + private val _downloadEnCours = MutableStateFlow<Set<Long>>(emptySet()) | |
| 64 | + val downloadEnCours: StateFlow<Set<Long>> = _downloadEnCours | |
| 65 | + | |
| 66 | + /** Confirmation à afficher après ajout d'une note vocale ; consommer via [consommerConfirmation]. */ | |
| 67 | + private val _confirmationNote = MutableStateFlow<ConfirmationNote?>(null) | |
| 68 | + val confirmationNote: StateFlow<ConfirmationNote?> = _confirmationNote.asStateFlow() | |
| 69 | + | |
| 70 | + fun consommerConfirmation() { _confirmationNote.value = null } | |
| 71 | + | |
| 72 | + /** Message d'erreur ponctuel (relance hors ligne, etc.) ; consommer via [consommerMessageErreur]. */ | |
| 73 | + private val _messageErreur = MutableStateFlow<String?>(null) | |
| 74 | + val messageErreur: StateFlow<String?> = _messageErreur.asStateFlow() | |
| 75 | + | |
| 76 | + fun consommerMessageErreur() { _messageErreur.value = null } | |
| 77 | + | |
| 78 | + /** Fabrique l'API client ; remplaçable dans les tests. */ | |
| 79 | + internal var apiFactory: (String, String) -> AilianceApi = { baseUrl, apiKey -> AilianceApiClient(baseUrl, apiKey) } | |
| 80 | + | |
| 39 | 81 | fun initialPageIndex(list: List<CrmContactEntity>): Int { |
| 40 | 82 | val idx = list.indexOfFirst { it.id == initialContactId } |
| 41 | 83 | return if (idx >= 0) idx else 0 |
@@ -47,4 +89,152 @@ class ContactPagerViewModel(
| 47 | 89 | repository.rotateCardImage(contactId, degrees = -90) |
| 48 | 90 | } |
| 49 | 91 | } |
| 92 | + | |
| 93 | + /** Observe les interactions de type note_vocale pour un contact (triées par date desc). */ | |
| 94 | + fun observeNotesVocales(contactServerId: String): Flow<List<InteractionEntity>> = | |
| 95 | + database.interactionDao().observeByContactServerId(contactServerId) | |
| 96 | + .map { list -> list.filter { it.type == "note_vocale" }.sortedByDescending { it.createdAt } } | |
| 97 | + | |
| 98 | + /** | |
| 99 | + * Insère une interaction note_vocale et l'op de sync correspondante. | |
| 100 | + * [modeServeur] = true → demandeTranscription=true + statut "en_attente" + push immédiat. | |
| 101 | + */ | |
| 102 | + fun ajouterNoteVocale( | |
| 103 | + contactServerId: String, | |
| 104 | + sujet: String, | |
| 105 | + description: String, | |
| 106 | + audioPath: String?, | |
| 107 | + modeServeur: Boolean, | |
| 108 | + ) { | |
| 109 | + if (sujet.isBlank()) return | |
| 110 | + viewModelScope.launch { | |
| 111 | + val now = System.currentTimeMillis() | |
| 112 | + val fichierTemp = audioPath?.let { File(it) } | |
| 113 | + val entite = InteractionEntity( | |
| 114 | + contactServerId = contactServerId, | |
| 115 | + type = "note_vocale", | |
| 116 | + sujet = sujet, | |
| 117 | + description = description, | |
| 118 | + creePar = credentialsStore.userName.orEmpty(), | |
| 119 | + audioPath = audioPath, | |
| 120 | + transcriptionStatut = if (modeServeur && audioPath != null) "en_attente" else null, | |
| 121 | + createdAt = now, | |
| 122 | + ) | |
| 123 | + val localId = database.interactionDao().upsert(entite) | |
| 124 | + // Déplacer le WAV depuis le dossier temporaire vers le stockage définitif (filesDir) | |
| 125 | + val cheminFinal = if (fichierTemp != null && fichierTemp.exists()) { | |
| 126 | + withContext(Dispatchers.IO) { audioStore.deplacerInteractionAudio(localId, fichierTemp) } | |
| 127 | + } else audioPath | |
| 128 | + if (cheminFinal != audioPath) { | |
| 129 | + database.interactionDao().upsert(entite.copy(localId = localId, audioPath = cheminFinal)) | |
| 130 | + } | |
| 131 | + database.syncOpDao().insert( | |
| 132 | + SyncOpEntity( | |
| 133 | + entityType = "interaction", | |
| 134 | + op = "create", | |
| 135 | + payloadJson = syncJson.encodeToString( | |
| 136 | + CreateInteractionRequest( | |
| 137 | + sujet = sujet, | |
| 138 | + description = description, | |
| 139 | + typeInteraction = "note_vocale", | |
| 140 | + demandeTranscription = modeServeur && cheminFinal != null, | |
| 141 | + ), | |
| 142 | + ), | |
| 143 | + localId = localId, | |
| 144 | + serverId = contactServerId, | |
| 145 | + createdAt = now, | |
| 146 | + ), | |
| 147 | + ) | |
| 148 | + if (modeServeur) { | |
| 149 | + val engine = engineFabrique() | |
| 150 | + _confirmationNote.value = if (engine != null) { | |
| 151 | + val rapport = engine.pousserEnAttente() | |
| 152 | + if (rapport.echecs == 0) ConfirmationNote.SERVEUR_OK else ConfirmationNote.SERVEUR_REPLI | |
| 153 | + } else { | |
| 154 | + ConfirmationNote.SERVEUR_REPLI | |
| 155 | + } | |
| 156 | + } else { | |
| 157 | + _confirmationNote.value = ConfirmationNote.APPAREIL | |
| 158 | + } | |
| 159 | + } | |
| 160 | + } | |
| 161 | + | |
| 162 | + /** Télécharge l'audio d'une interaction depuis le serveur et met à jour audioPath. */ | |
| 163 | + fun telechargerAudioInteraction(interaction: InteractionEntity) { | |
| 164 | + if (interaction.audioPath != null) return | |
| 165 | + val interactionServerId = interaction.serverId ?: return | |
| 166 | + if (_downloadEnCours.value.contains(interaction.localId)) return | |
| 167 | + viewModelScope.launch { | |
| 168 | + _downloadEnCours.update { it + interaction.localId } | |
| 169 | + withContext(Dispatchers.IO) { | |
| 170 | + val baseUrl = credentialsStore.baseUrl ?: return@withContext | |
| 171 | + val apiKey = credentialsStore.apiKey ?: return@withContext | |
| 172 | + val api = apiFactory(baseUrl, apiKey) | |
| 173 | + when (val result = api.downloadInteractionAudio(interaction.contactServerId, interactionServerId)) { | |
| 174 | + is AilianceApiClient.ApiResult.Ok -> { | |
| 175 | + val path = audioStore.saveInteractionAudio(interaction.localId, "audio.wav", result.value) | |
| 176 | + database.interactionDao().upsert(interaction.copy(audioPath = path)) | |
| 177 | + } | |
| 178 | + is AilianceApiClient.ApiResult.Err -> Unit | |
| 179 | + } | |
| 180 | + } | |
| 181 | + _downloadEnCours.update { it - interaction.localId } | |
| 182 | + } | |
| 183 | + } | |
| 184 | + | |
| 185 | + /** | |
| 186 | + * Supprime une note vocale : fichier audio local, entité DB, et op de sync delete | |
| 187 | + * (seulement si la note a été synchronisée avec le serveur). | |
| 188 | + */ | |
| 189 | + fun supprimerNoteVocale(interaction: InteractionEntity) { | |
| 190 | + viewModelScope.launch { | |
| 191 | + withContext(Dispatchers.IO) { audioStore.deleteInteractionAudio(interaction.localId) } | |
| 192 | + val serverId = interaction.serverId | |
| 193 | + if (serverId != null) { | |
| 194 | + database.interactionDao().deleteByServerId(serverId) | |
| 195 | + database.syncOpDao().insert( | |
| 196 | + SyncOpEntity( | |
| 197 | + entityType = "interaction", | |
| 198 | + op = "delete", | |
| 199 | + payloadJson = "{}", | |
| 200 | + serverId = serverId, | |
| 201 | + createdAt = System.currentTimeMillis(), | |
| 202 | + ), | |
| 203 | + ) | |
| 204 | + } else { | |
| 205 | + // Jamais synchronisée : suppression locale + annulation du create en attente | |
| 206 | + database.interactionDao().deleteByLocalId(interaction.localId) | |
| 207 | + database.syncOpDao().listAll() | |
| 208 | + .filter { it.entityType == "interaction" && it.localId == interaction.localId } | |
| 209 | + .forEach { database.syncOpDao().deleteById(it.id) } | |
| 210 | + } | |
| 211 | + } | |
| 212 | + } | |
| 213 | + | |
| 214 | + /** | |
| 215 | + * Relance la transcription d'une interaction (appel direct API, hors sync). | |
| 216 | + * Met à jour le statut local en `en_attente` et efface l'erreur si la relance réussit. | |
| 217 | + */ | |
| 218 | + fun relancerTranscriptionInteraction(interaction: InteractionEntity) { | |
| 219 | + val iid = interaction.serverId ?: return | |
| 220 | + viewModelScope.launch { | |
| 221 | + withContext(Dispatchers.IO) { | |
| 222 | + val baseUrl = credentialsStore.baseUrl | |
| 223 | + val apiKey = credentialsStore.apiKey | |
| 224 | + if (baseUrl == null || apiKey == null) { | |
| 225 | + _messageErreur.value = "relance_impossible" | |
| 226 | + return@withContext | |
| 227 | + } | |
| 228 | + val api = apiFactory(baseUrl, apiKey) | |
| 229 | + when (api.relancerTranscriptionInteraction(interaction.contactServerId, iid)) { | |
| 230 | + is AilianceApiClient.ApiResult.Ok -> | |
| 231 | + database.interactionDao().upsert( | |
| 232 | + interaction.copy(transcriptionStatut = "en_attente", transcriptionErreur = null), | |
| 233 | + ) | |
| 234 | + is AilianceApiClient.ApiResult.Err -> | |
| 235 | + _messageErreur.value = "relance_impossible" | |
| 236 | + } | |
| 237 | + } | |
| 238 | + } | |
| 239 | + } | |
| 50 | 240 | } |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/nav/Card2vcfNavHost.kt
+15
-5
@@ -18,6 +18,7 @@ import androidx.navigation.compose.rememberNavController
| 18 | 18 | import androidx.navigation.navArgument |
| 19 | 19 | import fr.ebii.card2vcf.contact.ContactCard |
| 20 | 20 | import fr.ebii.card2vcf.crm.ContactSort |
| 21 | +import fr.ebii.card2vcf.data.AudioNoteStore | |
| 21 | 22 | import fr.ebii.card2vcf.data.ContactRepository |
| 22 | 23 | import fr.ebii.card2vcf.data.CrmDatabase |
| 23 | 24 | import fr.ebii.card2vcf.ocr.TesseractOcrEngine |
@@ -27,6 +28,7 @@ import fr.ebii.card2vcf.sync.CalendarBindingsStore
| 27 | 28 | import fr.ebii.card2vcf.sync.CalendarBridge |
| 28 | 29 | import fr.ebii.card2vcf.sync.SyncCredentialsStore |
| 29 | 30 | import fr.ebii.card2vcf.sync.SyncEngine |
| 31 | +import fr.ebii.card2vcf.sync.creerSyncEngine | |
| 30 | 32 | import fr.ebii.card2vcf.ui.ScanCarteScreen |
| 31 | 33 | import fr.ebii.card2vcf.ui.ScanCarteViewModel |
| 32 | 34 | import fr.ebii.card2vcf.ui.carnet.CarnetScreen |
@@ -97,8 +99,10 @@ fun Card2vcfNavHost(
| 97 | 99 | val credentialsStore = remember { SyncCredentialsStore(context.applicationContext) } |
| 98 | 100 | val calendarBindingsStore = remember { CalendarBindingsStore(context.applicationContext) } |
| 99 | 101 | val calendarBridge = remember { CalendarBridge(context.applicationContext.contentResolver) } |
| 100 | - val settingsVm = remember(credentialsStore, calendarBindingsStore, calendarBridge) { | |
| 101 | - SettingsViewModel(credentialsStore, calendarBindingsStore, calendarBridge) | |
| 102 | + val audioStore = remember { AudioNoteStore(context.applicationContext) } | |
| 103 | + val voskDisponible = remember { context.assets.list("")?.contains("vosk-model-small-fr-0.22") == true } | |
| 104 | + val settingsVm = remember(credentialsStore, calendarBindingsStore, calendarBridge, voskDisponible) { | |
| 105 | + SettingsViewModel(credentialsStore, calendarBindingsStore, calendarBridge, voskDisponible) | |
| 102 | 106 | } |
| 103 | 107 | val projetsVm = remember(database) { ProjetsViewModel(database) } |
| 104 | 108 | val syncChromeVm = remember(credentialsStore, calendarBindingsStore, calendarBridge, database) { |
@@ -167,11 +171,14 @@ fun Card2vcfNavHost(
| 167 | 171 | ) { entry -> |
| 168 | 172 | val serverId = entry.arguments?.getString("serverId") ?: return@composable |
| 169 | 173 | val projetDetailVm = remember(database, serverId) { |
| 170 | - ProjetDetailViewModel(database, credentialsStore, serverId) | |
| 174 | + ProjetDetailViewModel(database, credentialsStore, audioStore, serverId, | |
| 175 | + engineFabrique = { creerSyncEngine(credentialsStore, database) }) | |
| 171 | 176 | } |
| 172 | 177 | ProjetDetailScreen( |
| 173 | 178 | viewModel = projetDetailVm, |
| 174 | 179 | onBack = { navController.popBackStack() }, |
| 180 | + modeServeur = !credentialsStore.modeTranscriptionLocal, | |
| 181 | + voskDisponible = voskDisponible, | |
| 175 | 182 | ) |
| 176 | 183 | } |
| 177 | 184 | composable( |
@@ -190,8 +197,9 @@ fun Card2vcfNavHost(
| 190 | 197 | val sort = sortName |
| 191 | 198 | ?.let { runCatching { ContactSort.valueOf(it) }.getOrNull() } |
| 192 | 199 | ?: ContactSort.LAST_NAME |
| 193 | - val pagerVm = remember(repository, id, sort) { | |
| 194 | - ContactPagerViewModel(repository, id, sort) | |
| 200 | + val pagerVm = remember(repository, database, id, sort) { | |
| 201 | + ContactPagerViewModel(repository, database, credentialsStore, audioStore, id, sort, | |
| 202 | + engineFabrique = { creerSyncEngine(credentialsStore, database) }) | |
| 195 | 203 | } |
| 196 | 204 | ContactPagerScreen( |
| 197 | 205 | viewModel = pagerVm, |
@@ -200,6 +208,8 @@ fun Card2vcfNavHost(
| 200 | 208 | onDuplicates = { contactId -> |
| 201 | 209 | navController.navigate(Routes.duplicates(contactId)) |
| 202 | 210 | }, |
| 211 | + modeServeur = !credentialsStore.modeTranscriptionLocal, | |
| 212 | + voskDisponible = voskDisponible, | |
| 203 | 213 | ) |
| 204 | 214 | } |
| 205 | 215 | composable( |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/projets/ProjetDetailScreen.kt
+187
-1
@@ -1,18 +1,24 @@
| 1 | 1 | package fr.ebii.card2vcf.ui.projets |
| 2 | 2 | |
| 3 | +import android.text.format.DateUtils | |
| 4 | +import android.widget.Toast | |
| 3 | 5 | import androidx.compose.foundation.background |
| 6 | +import androidx.compose.foundation.layout.Arrangement | |
| 4 | 7 | import androidx.compose.foundation.layout.Box |
| 5 | 8 | import androidx.compose.foundation.layout.Column |
| 9 | +import androidx.compose.foundation.layout.Row | |
| 6 | 10 | import androidx.compose.foundation.layout.Spacer |
| 7 | 11 | import androidx.compose.foundation.layout.fillMaxSize |
| 8 | 12 | import androidx.compose.foundation.layout.fillMaxWidth |
| 9 | 13 | import androidx.compose.foundation.layout.height |
| 10 | 14 | import androidx.compose.foundation.layout.padding |
| 11 | 15 | import androidx.compose.foundation.lazy.LazyColumn |
| 12 | -import androidx.compose.foundation.shape.RoundedCornerShape | |
| 13 | 16 | import androidx.compose.material.icons.Icons |
| 14 | 17 | import androidx.compose.material.icons.automirrored.outlined.ArrowBack |
| 15 | 18 | import androidx.compose.material.icons.filled.ArrowDropDown |
| 19 | +import androidx.compose.material.icons.filled.Delete | |
| 20 | +import androidx.compose.material.icons.filled.Mic | |
| 21 | +import androidx.compose.material3.AlertDialog | |
| 16 | 22 | import androidx.compose.material3.DropdownMenu |
| 17 | 23 | import androidx.compose.material3.DropdownMenuItem |
| 18 | 24 | import androidx.compose.material3.HorizontalDivider |
@@ -24,18 +30,27 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
| 24 | 30 | import androidx.compose.material3.Text |
| 25 | 31 | import androidx.compose.material3.TextButton |
| 26 | 32 | import androidx.compose.runtime.Composable |
| 33 | +import androidx.compose.runtime.LaunchedEffect | |
| 27 | 34 | import androidx.compose.runtime.collectAsState |
| 28 | 35 | import androidx.compose.runtime.getValue |
| 29 | 36 | import androidx.compose.runtime.mutableStateOf |
| 30 | 37 | import androidx.compose.runtime.remember |
| 31 | 38 | import androidx.compose.runtime.setValue |
| 32 | 39 | import androidx.compose.ui.Alignment |
| 40 | +import androidx.compose.ui.platform.LocalContext | |
| 33 | 41 | import androidx.compose.ui.Modifier |
| 34 | 42 | import androidx.compose.ui.res.stringResource |
| 35 | 43 | import androidx.compose.ui.unit.dp |
| 36 | 44 | import fr.ebii.card2vcf.R |
| 37 | 45 | import fr.ebii.card2vcf.data.CrmContactEntity |
| 38 | 46 | import fr.ebii.card2vcf.data.InteractionEntity |
| 47 | +import fr.ebii.card2vcf.data.NoteProjetEntity | |
| 48 | +import fr.ebii.card2vcf.ui.audio.ConfirmationNote | |
| 49 | +import fr.ebii.card2vcf.ui.audio.EnregistrementNoteSheet | |
| 50 | +import fr.ebii.card2vcf.ui.audio.LecteurAudio | |
| 51 | +import fr.ebii.card2vcf.ui.composants.BadgeStatutTranscription | |
| 52 | +import fr.ebii.card2vcf.ui.composants.EtatVide | |
| 53 | +import fr.ebii.card2vcf.ui.composants.MarkdownText | |
| 39 | 54 | import fr.ebii.card2vcf.ui.kanban.KanbanBoard |
| 40 | 55 | import fr.ebii.card2vcf.ui.theme.Bordure |
| 41 | 56 | import fr.ebii.card2vcf.ui.theme.Fond |
@@ -48,6 +63,8 @@ import fr.ebii.card2vcf.ui.theme.TexteFaible
| 48 | 63 | fun ProjetDetailScreen( |
| 49 | 64 | viewModel: ProjetDetailViewModel, |
| 50 | 65 | onBack: () -> Unit, |
| 66 | + modeServeur: Boolean, | |
| 67 | + voskDisponible: Boolean, | |
| 51 | 68 | modifier: Modifier = Modifier, |
| 52 | 69 | ) { |
| 53 | 70 | val projet by viewModel.projet.collectAsState() |
@@ -60,9 +77,31 @@ fun ProjetDetailScreen(
| 60 | 77 | val crSujet by viewModel.crSujet.collectAsState() |
| 61 | 78 | val crDescription by viewModel.crDescription.collectAsState() |
| 62 | 79 | val crInteractions by viewModel.crInteractions.collectAsState() |
| 80 | + val notes by viewModel.notes.collectAsState() | |
| 81 | + val downloadEnCours by viewModel.downloadEnCours.collectAsState() | |
| 82 | + val confirmationNote by viewModel.confirmationNote.collectAsState() | |
| 83 | + val messageErreur by viewModel.messageErreur.collectAsState() | |
| 63 | 84 | val assignableUsers = remember(membres, viewModel.userName) { |
| 64 | 85 | (membres.map { it.utilisateur } + viewModel.userName).filter { it.isNotBlank() } |
| 65 | 86 | } |
| 87 | + var sheetVisible by remember { mutableStateOf(false) } | |
| 88 | + | |
| 89 | + val context = LocalContext.current | |
| 90 | + LaunchedEffect(confirmationNote) { | |
| 91 | + val confirmation = confirmationNote ?: return@LaunchedEffect | |
| 92 | + val msgId = when (confirmation) { | |
| 93 | + ConfirmationNote.APPAREIL -> R.string.note_vocale_confirmation_appareil | |
| 94 | + ConfirmationNote.SERVEUR_OK -> R.string.note_vocale_confirmation_serveur_envoyee | |
| 95 | + ConfirmationNote.SERVEUR_REPLI -> R.string.note_vocale_confirmation_serveur_repli | |
| 96 | + } | |
| 97 | + Toast.makeText(context, msgId, Toast.LENGTH_LONG).show() | |
| 98 | + viewModel.consommerConfirmation() | |
| 99 | + } | |
| 100 | + LaunchedEffect(messageErreur) { | |
| 101 | + if (messageErreur == null) return@LaunchedEffect | |
| 102 | + Toast.makeText(context, R.string.note_vocale_relance_impossible, Toast.LENGTH_LONG).show() | |
| 103 | + viewModel.consommerMessageErreur() | |
| 104 | + } | |
| 66 | 105 | |
| 67 | 106 | Column(modifier.fillMaxSize().background(Fond)) { |
| 68 | 107 | Box(Modifier.fillMaxWidth().background(Surface).padding(horizontal = 8.dp, vertical = 6.dp)) { |
@@ -121,10 +160,31 @@ fun ProjetDetailScreen(
| 121 | 160 | interactions = crInteractions, |
| 122 | 161 | onSubmit = viewModel::submitCr, |
| 123 | 162 | ) |
| 163 | + | |
| 164 | + HorizontalDivider(color = Bordure, thickness = 1.dp, modifier = Modifier.padding(vertical = 12.dp)) | |
| 165 | + NotesSection( | |
| 166 | + notes = notes, | |
| 167 | + downloadEnCours = downloadEnCours, | |
| 168 | + onAjouterNoteVocale = { sheetVisible = true }, | |
| 169 | + onTelechargementNote = viewModel::telechargerAudioNote, | |
| 170 | + onSupprimerNote = viewModel::supprimerNote, | |
| 171 | + onRelancerNote = viewModel::relancerTranscriptionNote, | |
| 172 | + ) | |
| 124 | 173 | Spacer(Modifier.height(24.dp)) |
| 125 | 174 | } |
| 126 | 175 | } |
| 127 | 176 | } |
| 177 | + | |
| 178 | + if (sheetVisible) { | |
| 179 | + EnregistrementNoteSheet( | |
| 180 | + modeServeur = modeServeur, | |
| 181 | + voskDisponible = voskDisponible, | |
| 182 | + onSauvegarder = { sujet, texte, audioPath -> | |
| 183 | + viewModel.ajouterNoteVocale(sujet, texte, audioPath, modeServeur) | |
| 184 | + }, | |
| 185 | + onDismiss = { sheetVisible = false }, | |
| 186 | + ) | |
| 187 | + } | |
| 128 | 188 | } |
| 129 | 189 | |
| 130 | 190 | @Composable |
@@ -216,3 +276,129 @@ private fun contactDisplayName(contact: CrmContactEntity): String {
| 216 | 276 | val composed = listOfNotNull(contact.firstName, contact.lastName).joinToString(" ").trim() |
| 217 | 277 | return composed.ifEmpty { "—" } |
| 218 | 278 | } |
| 279 | + | |
| 280 | +@Composable | |
| 281 | +private fun NotesSection( | |
| 282 | + notes: List<NoteProjetEntity>, | |
| 283 | + downloadEnCours: Set<Long>, | |
| 284 | + onAjouterNoteVocale: () -> Unit, | |
| 285 | + onTelechargementNote: (NoteProjetEntity) -> Unit, | |
| 286 | + onSupprimerNote: (NoteProjetEntity) -> Unit, | |
| 287 | + onRelancerNote: (NoteProjetEntity) -> Unit, | |
| 288 | +) { | |
| 289 | + var pendingDelete by remember { mutableStateOf<NoteProjetEntity?>(null) } | |
| 290 | + | |
| 291 | + Row( | |
| 292 | + Modifier.fillMaxWidth(), | |
| 293 | + verticalAlignment = Alignment.CenterVertically, | |
| 294 | + horizontalArrangement = Arrangement.SpaceBetween, | |
| 295 | + ) { | |
| 296 | + Text( | |
| 297 | + stringResource(R.string.projet_notes_titre), | |
| 298 | + style = MaterialTheme.typography.labelMedium, | |
| 299 | + color = TexteFaible, | |
| 300 | + ) | |
| 301 | + IconButton(onClick = onAjouterNoteVocale) { | |
| 302 | + Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.note_vocale_enregistrer), tint = Ink) | |
| 303 | + } | |
| 304 | + } | |
| 305 | + Spacer(Modifier.height(6.dp)) | |
| 306 | + if (notes.isEmpty()) { | |
| 307 | + EtatVide( | |
| 308 | + texte = stringResource(R.string.projet_notes_vide), | |
| 309 | + modifier = Modifier.fillMaxWidth().height(80.dp), | |
| 310 | + ) | |
| 311 | + } else { | |
| 312 | + notes.forEach { note -> | |
| 313 | + NoteCard( | |
| 314 | + note = note, | |
| 315 | + enChargement = downloadEnCours.contains(note.localId), | |
| 316 | + onTelechargement = { onTelechargementNote(note) }, | |
| 317 | + onSupprimer = { pendingDelete = note }, | |
| 318 | + onRelancer = if (note.transcriptionStatut == "echec") { { onRelancerNote(note) } } else null, | |
| 319 | + ) | |
| 320 | + HorizontalDivider(color = Bordure, thickness = 1.dp) | |
| 321 | + } | |
| 322 | + } | |
| 323 | + | |
| 324 | + pendingDelete?.let { note -> | |
| 325 | + AlertDialog( | |
| 326 | + onDismissRequest = { pendingDelete = null }, | |
| 327 | + title = { Text(stringResource(R.string.note_vocale_supprimer_titre)) }, | |
| 328 | + text = { Text(stringResource(R.string.note_vocale_supprimer_message)) }, | |
| 329 | + confirmButton = { | |
| 330 | + TextButton(onClick = { | |
| 331 | + onSupprimerNote(note) | |
| 332 | + pendingDelete = null | |
| 333 | + }) { | |
| 334 | + Text(stringResource(R.string.note_vocale_supprimer_confirmer)) | |
| 335 | + } | |
| 336 | + }, | |
| 337 | + dismissButton = { | |
| 338 | + TextButton(onClick = { pendingDelete = null }) { | |
| 339 | + Text(stringResource(R.string.note_vocale_supprimer_annuler)) | |
| 340 | + } | |
| 341 | + }, | |
| 342 | + ) | |
| 343 | + } | |
| 344 | +} | |
| 345 | + | |
| 346 | +@Composable | |
| 347 | +private fun NoteCard( | |
| 348 | + note: NoteProjetEntity, | |
| 349 | + enChargement: Boolean, | |
| 350 | + onTelechargement: () -> Unit, | |
| 351 | + onSupprimer: () -> Unit, | |
| 352 | + onRelancer: (() -> Unit)?, | |
| 353 | +) { | |
| 354 | + Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { | |
| 355 | + Row( | |
| 356 | + Modifier.fillMaxWidth(), | |
| 357 | + verticalAlignment = Alignment.CenterVertically, | |
| 358 | + horizontalArrangement = Arrangement.SpaceBetween, | |
| 359 | + ) { | |
| 360 | + if (note.titre.isNotBlank()) { | |
| 361 | + Text(note.titre, style = MaterialTheme.typography.bodyMedium, color = Ink, modifier = Modifier.weight(1f)) | |
| 362 | + } | |
| 363 | + IconButton(onClick = onSupprimer) { | |
| 364 | + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.note_vocale_supprimer), tint = TexteFaible) | |
| 365 | + } | |
| 366 | + } | |
| 367 | + BadgeStatutTranscription(note.transcriptionStatut, note.transcriptionErreur) | |
| 368 | + val ts = note.updatedAt ?: note.createdAt | |
| 369 | + val metaText = buildString { | |
| 370 | + if (note.auteur.isNotBlank()) append(note.auteur) | |
| 371 | + if (ts > 0L) { | |
| 372 | + if (isNotEmpty()) append(" · ") | |
| 373 | + append( | |
| 374 | + DateUtils.getRelativeTimeSpanString( | |
| 375 | + ts, | |
| 376 | + System.currentTimeMillis(), | |
| 377 | + DateUtils.MINUTE_IN_MILLIS, | |
| 378 | + ), | |
| 379 | + ) | |
| 380 | + } | |
| 381 | + } | |
| 382 | + if (metaText.isNotEmpty()) { | |
| 383 | + Text(metaText, style = MaterialTheme.typography.bodySmall, color = TexteFaible) | |
| 384 | + } | |
| 385 | + if (note.texte.isNotBlank()) { | |
| 386 | + Spacer(Modifier.height(4.dp)) | |
| 387 | + MarkdownText(note.texte, modifier = Modifier.fillMaxWidth()) | |
| 388 | + } | |
| 389 | + // Lecteur audio — visible si le fichier existe localement ou si un serverId permet le téléchargement | |
| 390 | + if (note.audioPath != null || note.serverId != null) { | |
| 391 | + LecteurAudio( | |
| 392 | + audioPath = note.audioPath, | |
| 393 | + enChargement = enChargement, | |
| 394 | + onTelechargement = if (note.audioPath == null && note.serverId != null) onTelechargement else null, | |
| 395 | + ) | |
| 396 | + } | |
| 397 | + if (onRelancer != null) { | |
| 398 | + TextButton(onClick = onRelancer) { | |
| 399 | + Text(stringResource(R.string.note_vocale_relancer)) | |
| 400 | + } | |
| 401 | + } | |
| 402 | + } | |
| 403 | +} | |
| 404 | + |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/projets/ProjetDetailViewModel.kt
+180
-2
@@ -2,23 +2,32 @@ package fr.ebii.card2vcf.ui.projets
| 2 | 2 | |
| 3 | 3 | import androidx.lifecycle.ViewModel |
| 4 | 4 | import androidx.lifecycle.viewModelScope |
| 5 | +import fr.ebii.card2vcf.data.AudioNoteStore | |
| 5 | 6 | import fr.ebii.card2vcf.data.CrmContactEntity |
| 6 | 7 | import fr.ebii.card2vcf.data.CrmDatabase |
| 7 | 8 | import fr.ebii.card2vcf.data.InteractionEntity |
| 9 | +import fr.ebii.card2vcf.data.NoteProjetEntity | |
| 8 | 10 | import fr.ebii.card2vcf.data.ProjetEntity |
| 9 | 11 | import fr.ebii.card2vcf.data.TacheEntity |
| 10 | 12 | import fr.ebii.card2vcf.kanban.KanbanFilter |
| 13 | +import fr.ebii.card2vcf.sync.AilianceApi | |
| 14 | +import fr.ebii.card2vcf.sync.AilianceApiClient | |
| 11 | 15 | import fr.ebii.card2vcf.sync.ColonneDto |
| 12 | 16 | import fr.ebii.card2vcf.sync.CreateInteractionRequest |
| 17 | +import fr.ebii.card2vcf.sync.CreateNoteProjetRequest | |
| 13 | 18 | import fr.ebii.card2vcf.sync.CreateTacheRequest |
| 14 | 19 | import fr.ebii.card2vcf.sync.MembreProjetDto |
| 15 | 20 | import fr.ebii.card2vcf.sync.MoveTacheRequest |
| 16 | 21 | import fr.ebii.card2vcf.sync.SyncCredentialsStore |
| 22 | +import fr.ebii.card2vcf.sync.SyncEngine | |
| 17 | 23 | import fr.ebii.card2vcf.sync.SyncOpDao |
| 18 | 24 | import fr.ebii.card2vcf.sync.SyncOpEntity |
| 19 | 25 | import fr.ebii.card2vcf.sync.UpdateTacheRequest |
| 20 | 26 | import fr.ebii.card2vcf.sync.syncJson |
| 27 | +import fr.ebii.card2vcf.ui.audio.ConfirmationNote | |
| 28 | +import kotlinx.coroutines.Dispatchers | |
| 21 | 29 | import kotlinx.coroutines.ExperimentalCoroutinesApi |
| 30 | +import java.io.File | |
| 22 | 31 | import kotlinx.coroutines.flow.MutableStateFlow |
| 23 | 32 | import kotlinx.coroutines.flow.SharingStarted |
| 24 | 33 | import kotlinx.coroutines.flow.StateFlow |
@@ -29,20 +38,43 @@ import kotlinx.coroutines.flow.flatMapLatest
| 29 | 38 | import kotlinx.coroutines.flow.flow |
| 30 | 39 | import kotlinx.coroutines.flow.map |
| 31 | 40 | import kotlinx.coroutines.flow.stateIn |
| 41 | +import kotlinx.coroutines.flow.update | |
| 32 | 42 | import kotlinx.coroutines.launch |
| 43 | +import kotlinx.coroutines.withContext | |
| 33 | 44 | import kotlinx.serialization.decodeFromString |
| 34 | 45 | import kotlinx.serialization.encodeToString |
| 35 | 46 | |
| 36 | -/** Fiche projet : membres, kanban (colonnes du workflow), et CR (interaction) sur un contact. */ | |
| 47 | +/** Fiche projet : membres, kanban (colonnes du workflow), CR (interaction) et notes vocales. */ | |
| 37 | 48 | @OptIn(ExperimentalCoroutinesApi::class) |
| 38 | 49 | class ProjetDetailViewModel( |
| 39 | 50 | private val database: CrmDatabase, |
| 40 | - credentialsStore: SyncCredentialsStore, | |
| 51 | + private val credentialsStore: SyncCredentialsStore, | |
| 52 | + private val audioStore: AudioNoteStore, | |
| 41 | 53 | private val projetServerId: String, |
| 54 | + private val engineFabrique: () -> SyncEngine?, | |
| 42 | 55 | ) : ViewModel() { |
| 43 | 56 | |
| 44 | 57 | val userName: String = credentialsStore.userName.orEmpty() |
| 45 | 58 | |
| 59 | + /** localId des notes dont le téléchargement audio est en cours. */ | |
| 60 | + private val _downloadEnCours = MutableStateFlow<Set<Long>>(emptySet()) | |
| 61 | + val downloadEnCours: StateFlow<Set<Long>> = _downloadEnCours | |
| 62 | + | |
| 63 | + /** Confirmation à afficher après ajout d'une note vocale ; consommer via [consommerConfirmation]. */ | |
| 64 | + private val _confirmationNote = MutableStateFlow<ConfirmationNote?>(null) | |
| 65 | + val confirmationNote: StateFlow<ConfirmationNote?> = _confirmationNote.asStateFlow() | |
| 66 | + | |
| 67 | + fun consommerConfirmation() { _confirmationNote.value = null } | |
| 68 | + | |
| 69 | + /** Message d'erreur ponctuel (relance hors ligne, etc.) ; consommer via [consommerMessageErreur]. */ | |
| 70 | + private val _messageErreur = MutableStateFlow<String?>(null) | |
| 71 | + val messageErreur: StateFlow<String?> = _messageErreur.asStateFlow() | |
| 72 | + | |
| 73 | + fun consommerMessageErreur() { _messageErreur.value = null } | |
| 74 | + | |
| 75 | + /** Fabrique l'API client ; remplaçable dans les tests. */ | |
| 76 | + internal var apiFactory: (String, String) -> AilianceApi = { baseUrl, apiKey -> AilianceApiClient(baseUrl, apiKey) } | |
| 77 | + | |
| 46 | 78 | val projet: StateFlow<ProjetEntity?> = database.projetDao().observeAll() |
| 47 | 79 | .map { list -> list.find { it.serverId == projetServerId } } |
| 48 | 80 | .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) |
@@ -95,6 +127,11 @@ class ProjetDetailViewModel(
| 95 | 127 | } |
| 96 | 128 | .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) |
| 97 | 129 | |
| 130 | + val notes: StateFlow<List<NoteProjetEntity>> = database.noteProjetDao() | |
| 131 | + .listByProjetServerId(projetServerId) | |
| 132 | + .map { list -> list.sortedByDescending { it.updatedAt ?: it.createdAt } } | |
| 133 | + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) | |
| 134 | + | |
| 98 | 135 | fun setFilterMode(mode: KanbanFilter.Mode) { |
| 99 | 136 | _filterMode.value = mode |
| 100 | 137 | } |
@@ -262,6 +299,147 @@ class ProjetDetailViewModel(
| 262 | 299 | .forEach { syncOpDao.deleteById(it.id) } |
| 263 | 300 | } |
| 264 | 301 | |
| 302 | + /** | |
| 303 | + * Insère une note vocale pour ce projet. | |
| 304 | + * [modeServeur] = true → demandeTranscription + statut "en_attente" + push immédiat. | |
| 305 | + */ | |
| 306 | + fun ajouterNoteVocale( | |
| 307 | + sujet: String, | |
| 308 | + description: String, | |
| 309 | + audioPath: String?, | |
| 310 | + modeServeur: Boolean, | |
| 311 | + ) { | |
| 312 | + if (sujet.isBlank()) return | |
| 313 | + viewModelScope.launch { | |
| 314 | + val now = System.currentTimeMillis() | |
| 315 | + val fichierTemp = audioPath?.let { File(it) } | |
| 316 | + val entite = NoteProjetEntity( | |
| 317 | + projetServerId = projetServerId, | |
| 318 | + titre = sujet, | |
| 319 | + texte = description, | |
| 320 | + auteur = userName, | |
| 321 | + audioPath = audioPath, | |
| 322 | + transcriptionStatut = if (modeServeur && audioPath != null) "en_attente" else null, | |
| 323 | + createdAt = now, | |
| 324 | + updatedAt = now, | |
| 325 | + ) | |
| 326 | + val localId = database.noteProjetDao().upsert(entite) | |
| 327 | + // Déplacer le WAV depuis le dossier temporaire vers le stockage définitif (filesDir) | |
| 328 | + val cheminFinal = if (fichierTemp != null && fichierTemp.exists()) { | |
| 329 | + withContext(Dispatchers.IO) { audioStore.deplacerNoteAudio(localId, fichierTemp) } | |
| 330 | + } else audioPath | |
| 331 | + if (cheminFinal != audioPath) { | |
| 332 | + database.noteProjetDao().upsert(entite.copy(localId = localId, audioPath = cheminFinal)) | |
| 333 | + } | |
| 334 | + database.syncOpDao().insert( | |
| 335 | + SyncOpEntity( | |
| 336 | + entityType = "note_projet", | |
| 337 | + op = "create", | |
| 338 | + payloadJson = syncJson.encodeToString( | |
| 339 | + CreateNoteProjetRequest( | |
| 340 | + titre = sujet, | |
| 341 | + contenu = description, | |
| 342 | + demandeTranscription = modeServeur && cheminFinal != null, | |
| 343 | + ), | |
| 344 | + ), | |
| 345 | + localId = localId, | |
| 346 | + serverId = projetServerId, | |
| 347 | + createdAt = now, | |
| 348 | + ), | |
| 349 | + ) | |
| 350 | + if (modeServeur) { | |
| 351 | + val engine = engineFabrique() | |
| 352 | + _confirmationNote.value = if (engine != null) { | |
| 353 | + val rapport = engine.pousserEnAttente() | |
| 354 | + if (rapport.echecs == 0) ConfirmationNote.SERVEUR_OK else ConfirmationNote.SERVEUR_REPLI | |
| 355 | + } else { | |
| 356 | + ConfirmationNote.SERVEUR_REPLI | |
| 357 | + } | |
| 358 | + } else { | |
| 359 | + _confirmationNote.value = ConfirmationNote.APPAREIL | |
| 360 | + } | |
| 361 | + } | |
| 362 | + } | |
| 363 | + | |
| 364 | + /** Télécharge l'audio d'une note projet depuis le serveur et met à jour audioPath. */ | |
| 365 | + fun telechargerAudioNote(note: NoteProjetEntity) { | |
| 366 | + if (note.audioPath != null) return | |
| 367 | + val noteServerId = note.serverId ?: return | |
| 368 | + if (_downloadEnCours.value.contains(note.localId)) return | |
| 369 | + viewModelScope.launch { | |
| 370 | + _downloadEnCours.update { it + note.localId } | |
| 371 | + withContext(Dispatchers.IO) { | |
| 372 | + val baseUrl = credentialsStore.baseUrl ?: return@withContext | |
| 373 | + val apiKey = credentialsStore.apiKey ?: return@withContext | |
| 374 | + val api = apiFactory(baseUrl, apiKey) | |
| 375 | + when (val result = api.downloadNoteAudio(projetServerId, noteServerId)) { | |
| 376 | + is AilianceApiClient.ApiResult.Ok -> { | |
| 377 | + val path = audioStore.saveNoteAudio(note.localId, "audio.wav", result.value) | |
| 378 | + database.noteProjetDao().upsert(note.copy(audioPath = path)) | |
| 379 | + } | |
| 380 | + is AilianceApiClient.ApiResult.Err -> Unit | |
| 381 | + } | |
| 382 | + } | |
| 383 | + _downloadEnCours.update { it - note.localId } | |
| 384 | + } | |
| 385 | + } | |
| 386 | + | |
| 387 | + /** | |
| 388 | + * Supprime une note projet : fichier audio local, entité DB, et op de sync delete | |
| 389 | + * (seulement si la note a été synchronisée avec le serveur). | |
| 390 | + */ | |
| 391 | + fun supprimerNote(note: NoteProjetEntity) { | |
| 392 | + viewModelScope.launch { | |
| 393 | + withContext(Dispatchers.IO) { audioStore.deleteNoteAudio(note.localId) } | |
| 394 | + val serverId = note.serverId | |
| 395 | + if (serverId != null) { | |
| 396 | + database.noteProjetDao().deleteByServerId(serverId) | |
| 397 | + database.syncOpDao().insert( | |
| 398 | + SyncOpEntity( | |
| 399 | + entityType = "note_projet", | |
| 400 | + op = "delete", | |
| 401 | + payloadJson = "{}", | |
| 402 | + serverId = serverId, | |
| 403 | + createdAt = System.currentTimeMillis(), | |
| 404 | + ), | |
| 405 | + ) | |
| 406 | + } else { | |
| 407 | + // Jamais synchronisée : suppression locale + annulation du create en attente | |
| 408 | + database.noteProjetDao().deleteByLocalId(note.localId) | |
| 409 | + database.syncOpDao().listAll() | |
| 410 | + .filter { it.entityType == "note_projet" && it.localId == note.localId } | |
| 411 | + .forEach { database.syncOpDao().deleteById(it.id) } | |
| 412 | + } | |
| 413 | + } | |
| 414 | + } | |
| 415 | + | |
| 416 | + /** | |
| 417 | + * Relance la transcription d'une note projet (appel direct API, hors sync). | |
| 418 | + * Met à jour le statut local en `en_attente` et efface l'erreur si la relance réussit. | |
| 419 | + */ | |
| 420 | + fun relancerTranscriptionNote(note: NoteProjetEntity) { | |
| 421 | + val nid = note.serverId ?: return | |
| 422 | + viewModelScope.launch { | |
| 423 | + withContext(Dispatchers.IO) { | |
| 424 | + val baseUrl = credentialsStore.baseUrl | |
| 425 | + val apiKey = credentialsStore.apiKey | |
| 426 | + if (baseUrl == null || apiKey == null) { | |
| 427 | + _messageErreur.value = "relance_impossible" | |
| 428 | + return@withContext | |
| 429 | + } | |
| 430 | + val api = apiFactory(baseUrl, apiKey) | |
| 431 | + when (api.relancerTranscriptionNote(projetServerId, nid)) { | |
| 432 | + is AilianceApiClient.ApiResult.Ok -> | |
| 433 | + database.noteProjetDao().upsert( | |
| 434 | + note.copy(transcriptionStatut = "en_attente", transcriptionErreur = null), | |
| 435 | + ) | |
| 436 | + is AilianceApiClient.ApiResult.Err -> | |
| 437 | + _messageErreur.value = "relance_impossible" | |
| 438 | + } | |
| 439 | + } | |
| 440 | + } | |
| 441 | + } | |
| 442 | + | |
| 265 | 443 | private companion object { |
| 266 | 444 | fun parseMembres(json: String): List<MembreProjetDto> = |
| 267 | 445 | try { |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/settings/SettingsScreen.kt
+37
-0
@@ -145,6 +145,10 @@ private fun LoggedInContent(
| 145 | 145 | hasCalendarPermission = hasCalendarPermission, |
| 146 | 146 | onRequestCalendarPermission = onRequestCalendarPermission, |
| 147 | 147 | ) |
| 148 | + | |
| 149 | + HorizontalDivider(color = Bordure, thickness = 1.dp) | |
| 150 | + | |
| 151 | + TranscriptionSection(state = state, viewModel = viewModel) | |
| 148 | 152 | } |
| 149 | 153 | |
| 150 | 154 | if (state.pendingRemoval != null) { |
@@ -269,6 +273,39 @@ private fun ToggleRow(
| 269 | 273 | } |
| 270 | 274 | |
| 271 | 275 | @Composable |
| 276 | +private fun TranscriptionSection( | |
| 277 | + state: SettingsUiState.LoggedIn, | |
| 278 | + viewModel: SettingsViewModel, | |
| 279 | +) { | |
| 280 | + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { | |
| 281 | + Text( | |
| 282 | + stringResource(R.string.settings_transcription_titre), | |
| 283 | + style = MaterialTheme.typography.titleSmall, | |
| 284 | + color = Ink, | |
| 285 | + ) | |
| 286 | + if (!state.voskDisponible) { | |
| 287 | + Text( | |
| 288 | + stringResource(R.string.settings_transcription_vosk_indisponible), | |
| 289 | + color = MaterialTheme.colorScheme.error, | |
| 290 | + style = MaterialTheme.typography.bodySmall, | |
| 291 | + ) | |
| 292 | + } | |
| 293 | + ToggleRow( | |
| 294 | + label = stringResource(R.string.settings_transcription_local), | |
| 295 | + checked = state.modeTranscriptionLocal, | |
| 296 | + enabled = state.voskDisponible, | |
| 297 | + onCheckedChange = { viewModel.setModeTranscriptionLocal(true) }, | |
| 298 | + ) | |
| 299 | + ToggleRow( | |
| 300 | + label = stringResource(R.string.settings_transcription_serveur), | |
| 301 | + checked = !state.modeTranscriptionLocal, | |
| 302 | + enabled = true, | |
| 303 | + onCheckedChange = { viewModel.setModeTranscriptionLocal(false) }, | |
| 304 | + ) | |
| 305 | + } | |
| 306 | +} | |
| 307 | + | |
| 308 | +@Composable | |
| 272 | 309 | private fun LoggedOutContent( |
| 273 | 310 | state: SettingsUiState.LoggedOut, |
| 274 | 311 | viewModel: SettingsViewModel, |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/settings/SettingsViewModel.kt
+18
-0
@@ -51,6 +51,10 @@ sealed interface SettingsUiState {
| 51 | 51 | val catalogueLoading: Boolean = false, |
| 52 | 52 | val catalogueError: String? = null, |
| 53 | 53 | val pendingRemoval: PendingCalendarRemoval? = null, |
| 54 | + /** true = Sur l'appareil (Vosk) ; false = Sur le serveur. */ | |
| 55 | + val modeTranscriptionLocal: Boolean = true, | |
| 56 | + /** false si le modèle Vosk est absent des assets (build -Pvosk=none). */ | |
| 57 | + val voskDisponible: Boolean = true, | |
| 54 | 58 | ) : SettingsUiState |
| 55 | 59 | |
| 56 | 60 | data class LoggedOut( |
@@ -76,6 +80,8 @@ class SettingsViewModel(
| 76 | 80 | private val credentialsStore: SyncCredentialsStore, |
| 77 | 81 | private val calendarBindingsStore: CalendarBindingsStore, |
| 78 | 82 | private val calendarBridge: CalendarBridgeApi, |
| 83 | + /** false si le modèle Vosk est absent des assets — force le mode serveur en Paramètres. */ | |
| 84 | + private val voskDisponible: Boolean = true, | |
| 79 | 85 | private val apiFactory: (String) -> AilianceApi = { baseUrl -> AilianceApiClient(baseUrl) }, |
| 80 | 86 | private val authenticatedApiFactory: (String, String) -> AilianceApi = |
| 81 | 87 | { baseUrl, apiKey -> AilianceApiClient(baseUrl, apiKey) }, |
@@ -93,6 +99,8 @@ class SettingsViewModel(
| 93 | 99 | userName = userName, |
| 94 | 100 | baseUrl = baseUrl, |
| 95 | 101 | mesRdvLinked = calendarBindingsStore.list().any { it.kind == AgendaSyncCoordinator.KIND_RDV }, |
| 102 | + modeTranscriptionLocal = credentialsStore.modeTranscriptionLocal, | |
| 103 | + voskDisponible = voskDisponible, | |
| 96 | 104 | ) |
| 97 | 105 | } else { |
| 98 | 106 | SettingsUiState.LoggedOut( |
@@ -171,6 +179,8 @@ class SettingsViewModel(
| 171 | 179 | userName = auth.nom, |
| 172 | 180 | baseUrl = baseUrl, |
| 173 | 181 | mesRdvLinked = calendarBindingsStore.list().any { it.kind == AgendaSyncCoordinator.KIND_RDV }, |
| 182 | + modeTranscriptionLocal = credentialsStore.modeTranscriptionLocal, | |
| 183 | + voskDisponible = voskDisponible, | |
| 174 | 184 | ) |
| 175 | 185 | } catch (e: ApiCleCryptoException) { |
| 176 | 186 | _state.value = s.copy(retypePassword = "", retypeError = "Mot de passe incorrect") |
@@ -285,6 +295,14 @@ class SettingsViewModel(
| 285 | 295 | |
| 286 | 296 | fun dismissRemoveBinding() = updateLoggedIn { it.copy(pendingRemoval = null) } |
| 287 | 297 | |
| 298 | + // ---- Transcription ---- | |
| 299 | + | |
| 300 | + fun setModeTranscriptionLocal(local: Boolean) { | |
| 301 | + val effectif = local && voskDisponible | |
| 302 | + credentialsStore.modeTranscriptionLocal = effectif | |
| 303 | + updateLoggedIn { it.copy(modeTranscriptionLocal = effectif) } | |
| 304 | + } | |
| 305 | + | |
| 288 | 306 | private fun RessourceToggleState.withLinked(kind: String, serverResourceId: String?, linked: Boolean): RessourceToggleState = |
| 289 | 307 | if (this.kind == kind && this.serverResourceId == serverResourceId) copy(linked = linked) else this |
| 290 | 308 |
M
android/app/src/main/res/values-en/strings.xml
+37
-0
@@ -153,4 +153,41 @@
| 153 | 153 | <string name="kanban_supprimer">Delete</string> |
| 154 | 154 | <string name="kanban_valider">Save</string> |
| 155 | 155 | <string name="kanban_sans_workflow">No workflow linked to this project</string> |
| 156 | + | |
| 157 | + <!-- Voice notes — recording --> | |
| 158 | + <string name="note_vocale_titre_section">Voice notes</string> | |
| 159 | + <string name="note_vocale_enregistrer">Record a voice note</string> | |
| 160 | + <string name="note_vocale_avertissement">Transcription does not distinguish between speakers</string> | |
| 161 | + <string name="note_vocale_stop">Stop recording</string> | |
| 162 | + <string name="note_vocale_transcription_en_cours">Transcription in progress…</string> | |
| 163 | + <string name="note_vocale_transcription_serveur">Server transcription after sync</string> | |
| 164 | + <string name="note_vocale_sujet_label">Note subject</string> | |
| 165 | + <string name="note_vocale_texte_label">Transcription</string> | |
| 166 | + <string name="note_vocale_sauvegarder">Save</string> | |
| 167 | + <string name="note_vocale_annuler">Cancel</string> | |
| 168 | + <string name="note_vocale_permission_manquante">Allow microphone access to record voice notes.</string> | |
| 169 | + <string name="note_vocale_autoriser_micro">Allow microphone</string> | |
| 170 | + <string name="note_vocale_sujet_defaut">Notes from %1$s</string> | |
| 171 | + <string name="note_vocale_sujet_aide">A subject is required to save the note</string> | |
| 172 | + <string name="note_vocale_confirmation_serveur">Voice note saved — will be sent to the server at the next sync; transcription will appear afterwards.</string> | |
| 173 | + <string name="note_vocale_confirmation_appareil">Voice note saved — will be sent to the server at the next sync.</string> | |
| 174 | + <!-- Voice notes — status badges --> | |
| 175 | + <string name="note_vocale_badge_en_attente">Transcription pending</string> | |
| 176 | + <string name="note_vocale_badge_echec">Transcription failed</string> | |
| 177 | + <!-- Voice notes — list --> | |
| 178 | + <string name="note_vocale_vide">No voice notes</string> | |
| 179 | + <!-- Audio player --> | |
| 180 | + <string name="lecteur_audio_lire">Play</string> | |
| 181 | + <string name="lecteur_audio_pause">Pause</string> | |
| 182 | + <string name="lecteur_audio_telecharger">Download audio</string> | |
| 183 | + <string name="lecteur_audio_en_chargement">Loading…</string> | |
| 184 | + <string name="lecteur_audio_erreur">Unable to play audio</string> | |
| 185 | + <!-- Settings — transcription --> | |
| 186 | + <string name="settings_transcription_titre">Voice note transcription</string> | |
| 187 | + <string name="settings_transcription_local">On device</string> | |
| 188 | + <string name="settings_transcription_serveur">On server</string> | |
| 189 | + <string name="settings_transcription_vosk_indisponible">Local model unavailable — server transcription forced</string> | |
| 190 | + <string name="scan_champ_a_verifier">To verify — uncertain reading</string> | |
| 191 | + <string name="projet_notes_titre">Notes</string> | |
| 192 | + <string name="projet_notes_vide">No notes for this project</string> | |
| 156 | 193 | </resources> |
M
android/app/src/main/res/values/strings.xml
+48
-0
@@ -144,6 +144,9 @@
| 144 | 144 | <string name="projet_cr_description_placeholder">Description</string> |
| 145 | 145 | <string name="projet_cr_ajouter">Ajouter</string> |
| 146 | 146 | |
| 147 | + <string name="projet_notes_titre">Notes</string> | |
| 148 | + <string name="projet_notes_vide">Aucune note pour ce projet</string> | |
| 149 | + | |
| 147 | 150 | <string name="kanban_filtre_mes_taches">Mes tâches</string> |
| 148 | 151 | <string name="kanban_filtre_toutes">Toutes</string> |
| 149 | 152 | <string name="kanban_nouvelle_tache">Nouvelle tâche</string> |
@@ -154,4 +157,49 @@
| 154 | 157 | <string name="kanban_supprimer">Supprimer</string> |
| 155 | 158 | <string name="kanban_valider">Valider</string> |
| 156 | 159 | <string name="kanban_sans_workflow">Aucun workflow associé à ce projet</string> |
| 160 | + | |
| 161 | + <!-- Notes vocales — enregistrement --> | |
| 162 | + <string name="note_vocale_titre_section">Notes vocales</string> | |
| 163 | + <string name="note_vocale_enregistrer">Enregistrer une note vocale</string> | |
| 164 | + <string name="note_vocale_avertissement">La transcription ne distingue pas les interlocuteurs</string> | |
| 165 | + <string name="note_vocale_stop">Arrêter l\'enregistrement</string> | |
| 166 | + <string name="note_vocale_transcription_en_cours">Transcription en cours…</string> | |
| 167 | + <string name="note_vocale_transcription_serveur">Transcription serveur après synchronisation</string> | |
| 168 | + <string name="note_vocale_sujet_label">Sujet de la note</string> | |
| 169 | + <string name="note_vocale_texte_label">Transcription</string> | |
| 170 | + <string name="note_vocale_sauvegarder">Sauvegarder</string> | |
| 171 | + <string name="note_vocale_annuler">Annuler</string> | |
| 172 | + <string name="note_vocale_permission_manquante">Autorisez l\'accès au microphone pour enregistrer des notes vocales.</string> | |
| 173 | + <string name="note_vocale_autoriser_micro">Autoriser le microphone</string> | |
| 174 | + <string name="note_vocale_sujet_defaut">Notes du %1$s</string> | |
| 175 | + <string name="note_vocale_sujet_aide">Un sujet est nécessaire pour enregistrer la note</string> | |
| 176 | + <string name="note_vocale_confirmation_serveur">Note vocale enregistrée — envoyée au serveur à la prochaine synchronisation ; la transcription apparaîtra ensuite.</string> | |
| 177 | + <string name="note_vocale_confirmation_appareil">Note vocale enregistrée — envoyée au serveur à la prochaine synchronisation.</string> | |
| 178 | + <string name="note_vocale_confirmation_serveur_envoyee">Note vocale envoyée au serveur — transcription en cours, le texte apparaîtra à la prochaine synchronisation.</string> | |
| 179 | + <string name="note_vocale_confirmation_serveur_repli">Note vocale enregistrée — envoi au serveur à la prochaine synchronisation.</string> | |
| 180 | + <!-- Notes vocales — badges statut --> | |
| 181 | + <string name="note_vocale_badge_en_attente">Transcription en attente</string> | |
| 182 | + <string name="note_vocale_badge_echec">Échec de transcription</string> | |
| 183 | + <!-- Notes vocales — liste --> | |
| 184 | + <string name="note_vocale_vide">Aucune note vocale</string> | |
| 185 | + <!-- Notes vocales — suppression --> | |
| 186 | + <string name="note_vocale_supprimer">Supprimer la note</string> | |
| 187 | + <string name="note_vocale_supprimer_titre">Supprimer cette note ?</string> | |
| 188 | + <string name="note_vocale_supprimer_message">La note vocale et son audio local seront supprimés définitivement.</string> | |
| 189 | + <string name="note_vocale_supprimer_confirmer">Supprimer</string> | |
| 190 | + <string name="note_vocale_supprimer_annuler">Annuler</string> | |
| 191 | + <!-- Notes vocales — relance transcription --> | |
| 192 | + <string name="note_vocale_relancer">Relancer la transcription</string> | |
| 193 | + <string name="note_vocale_relance_impossible">Relance impossible : serveur injoignable</string> | |
| 194 | + <!-- Lecteur audio --> | |
| 195 | + <string name="lecteur_audio_lire">Lire</string> | |
| 196 | + <string name="lecteur_audio_pause">Pause</string> | |
| 197 | + <string name="lecteur_audio_telecharger">Télécharger l\'audio</string> | |
| 198 | + <string name="lecteur_audio_en_chargement">Chargement…</string> | |
| 199 | + <string name="lecteur_audio_erreur">Impossible de lire l\'audio</string> | |
| 200 | + <!-- Paramètres — transcription --> | |
| 201 | + <string name="settings_transcription_titre">Transcription des notes vocales</string> | |
| 202 | + <string name="settings_transcription_local">Sur l\'appareil</string> | |
| 203 | + <string name="settings_transcription_serveur">Sur le serveur</string> | |
| 204 | + <string name="settings_transcription_vosk_indisponible">Modèle local absent — transcription serveur forcée</string> | |
| 157 | 205 | </resources> |
A
android/app/src/test/java/fr/ebii/card2vcf/audio/EcritureWavTest.kt
+128
-0
@@ -0,0 +1,128 @@
| 1 | +package fr.ebii.card2vcf.audio | |
| 2 | + | |
| 3 | +import java.io.File | |
| 4 | +import java.nio.ByteBuffer | |
| 5 | +import java.nio.ByteOrder | |
| 6 | +import kotlin.test.Test | |
| 7 | +import kotlin.test.assertEquals | |
| 8 | +import kotlin.test.assertFailsWith | |
| 9 | + | |
| 10 | +class EcritureWavTest { | |
| 11 | + | |
| 12 | + @Test | |
| 13 | + fun enTeteRiffCorrect() { | |
| 14 | + val fichier = File.createTempFile("test_wav", ".wav") | |
| 15 | + try { | |
| 16 | + val ecriture = EcritureWav(fichier, 16_000) | |
| 17 | + // 160 échantillons = 10 ms à 16 kHz | |
| 18 | + val echantillons = ShortArray(160) { (it % 500).toShort() } | |
| 19 | + ecriture.ecrireEchantillons(echantillons, echantillons.size) | |
| 20 | + ecriture.fermer() | |
| 21 | + | |
| 22 | + val octets = fichier.readBytes() | |
| 23 | + val buf = ByteBuffer.wrap(octets).order(ByteOrder.LITTLE_ENDIAN) | |
| 24 | + | |
| 25 | + // Marqueur RIFF | |
| 26 | + assertEquals('R', buf.get().toInt().toChar()) | |
| 27 | + assertEquals('I', buf.get().toInt().toChar()) | |
| 28 | + assertEquals('F', buf.get().toInt().toChar()) | |
| 29 | + assertEquals('F', buf.get().toInt().toChar()) | |
| 30 | + | |
| 31 | + // Taille RIFF = taille fichier - 8 | |
| 32 | + val tailleRiff = buf.int | |
| 33 | + assertEquals(octets.size - 8, tailleRiff) | |
| 34 | + | |
| 35 | + // WAVE | |
| 36 | + assertEquals('W', buf.get().toInt().toChar()) | |
| 37 | + assertEquals('A', buf.get().toInt().toChar()) | |
| 38 | + assertEquals('V', buf.get().toInt().toChar()) | |
| 39 | + assertEquals('E', buf.get().toInt().toChar()) | |
| 40 | + | |
| 41 | + // Chunk fmt | |
| 42 | + assertEquals('f', buf.get().toInt().toChar()) | |
| 43 | + assertEquals('m', buf.get().toInt().toChar()) | |
| 44 | + assertEquals('t', buf.get().toInt().toChar()) | |
| 45 | + assertEquals(' ', buf.get().toInt().toChar()) | |
| 46 | + assertEquals(16, buf.int) // taille chunk fmt = 16 | |
| 47 | + assertEquals(1, buf.short.toInt()) // format PCM = 1 | |
| 48 | + assertEquals(1, buf.short.toInt()) // mono = 1 canal | |
| 49 | + assertEquals(16_000, buf.int) // fréquence d'échantillonnage | |
| 50 | + assertEquals(32_000, buf.int) // byte rate = 16000 * 1 * 16/8 | |
| 51 | + assertEquals(2, buf.short.toInt()) // block align = 1 * 16/8 | |
| 52 | + assertEquals(16, buf.short.toInt()) // bits par échantillon | |
| 53 | + | |
| 54 | + // Chunk data | |
| 55 | + assertEquals('d', buf.get().toInt().toChar()) | |
| 56 | + assertEquals('a', buf.get().toInt().toChar()) | |
| 57 | + assertEquals('t', buf.get().toInt().toChar()) | |
| 58 | + assertEquals('a', buf.get().toInt().toChar()) | |
| 59 | + | |
| 60 | + val tailleData = buf.int | |
| 61 | + assertEquals(echantillons.size * 2, tailleData) | |
| 62 | + assertEquals(octets.size - 44, tailleData) | |
| 63 | + } finally { | |
| 64 | + fichier.delete() | |
| 65 | + } | |
| 66 | + } | |
| 67 | + | |
| 68 | + @Test | |
| 69 | + fun octetsDataRetournes() { | |
| 70 | + val fichier = File.createTempFile("test_data", ".wav") | |
| 71 | + try { | |
| 72 | + val ecriture = EcritureWav(fichier, 16_000) | |
| 73 | + // 1 seconde = 16 000 échantillons → 32 000 octets | |
| 74 | + val echantillons = ShortArray(16_000) | |
| 75 | + ecriture.ecrireEchantillons(echantillons, echantillons.size) | |
| 76 | + val octetsEcrits = ecriture.fermer() | |
| 77 | + assertEquals(32_000L, octetsEcrits) | |
| 78 | + } finally { | |
| 79 | + fichier.delete() | |
| 80 | + } | |
| 81 | + } | |
| 82 | + | |
| 83 | + @Test | |
| 84 | + fun fichiertailleCoherente() { | |
| 85 | + val fichier = File.createTempFile("test_taille", ".wav") | |
| 86 | + try { | |
| 87 | + val ecriture = EcritureWav(fichier, 16_000) | |
| 88 | + val echantillons = ShortArray(800) // 50 ms | |
| 89 | + ecriture.ecrireEchantillons(echantillons, echantillons.size) | |
| 90 | + ecriture.fermer() | |
| 91 | + // 44 octets d'en-tête + 800 * 2 = 1644 octets | |
| 92 | + assertEquals(44 + 800 * 2, fichier.length().toInt()) | |
| 93 | + } finally { | |
| 94 | + fichier.delete() | |
| 95 | + } | |
| 96 | + } | |
| 97 | + | |
| 98 | + @Test | |
| 99 | + fun fermerLanceExceptionSiTropGrand() { | |
| 100 | + val fichier = File.createTempFile("test_check_taille", ".wav") | |
| 101 | + try { | |
| 102 | + val ecriture = EcritureWav(fichier, 16_000) | |
| 103 | + // Injecte une taille trop grande via réflexion (écrire 2 Go en test est impraticable) | |
| 104 | + val champ = EcritureWav::class.java.getDeclaredField("octetsData") | |
| 105 | + champ.isAccessible = true | |
| 106 | + champ.setLong(ecriture, Int.MAX_VALUE.toLong() + 1L) | |
| 107 | + assertFailsWith<IllegalStateException> { ecriture.fermer() } | |
| 108 | + } finally { | |
| 109 | + fichier.delete() | |
| 110 | + } | |
| 111 | + } | |
| 112 | + | |
| 113 | + @Test | |
| 114 | + fun ecritureEnPlusieursAppels() { | |
| 115 | + val fichier = File.createTempFile("test_multi", ".wav") | |
| 116 | + try { | |
| 117 | + val ecriture = EcritureWav(fichier, 16_000) | |
| 118 | + ecriture.ecrireEchantillons(ShortArray(100), 100) | |
| 119 | + ecriture.ecrireEchantillons(ShortArray(200), 200) | |
| 120 | + ecriture.ecrireEchantillons(ShortArray(50), 50) | |
| 121 | + val octetsEcrits = ecriture.fermer() | |
| 122 | + assertEquals((100 + 200 + 50) * 2L, octetsEcrits) | |
| 123 | + assertEquals(44 + (100 + 200 + 50) * 2, fichier.length().toInt()) | |
| 124 | + } finally { | |
| 125 | + fichier.delete() | |
| 126 | + } | |
| 127 | + } | |
| 128 | +} |
A
android/app/src/test/java/fr/ebii/card2vcf/audio/NoteVocaleUtilTest.kt
+56
-0
@@ -0,0 +1,56 @@
| 1 | +package fr.ebii.card2vcf.audio | |
| 2 | + | |
| 3 | +import fr.ebii.card2vcf.ui.audio.genererSujetParDefaut | |
| 4 | +import java.util.Calendar | |
| 5 | +import kotlin.test.Test | |
| 6 | +import kotlin.test.assertEquals | |
| 7 | +import kotlin.test.assertTrue | |
| 8 | + | |
| 9 | +class NoteVocaleUtilTest { | |
| 10 | + | |
| 11 | + private fun horodatage(annee: Int, mois: Int, jour: Int, heure: Int, minute: Int): Long { | |
| 12 | + return Calendar.getInstance().apply { | |
| 13 | + set(annee, mois - 1, jour, heure, minute, 0) | |
| 14 | + set(Calendar.MILLISECOND, 0) | |
| 15 | + }.timeInMillis | |
| 16 | + } | |
| 17 | + | |
| 18 | + @Test | |
| 19 | + fun sujet_formate_date_complete() { | |
| 20 | + val ts = horodatage(2026, 9, 5, 9, 7) | |
| 21 | + val resultat = genererSujetParDefaut("Notes du %1\$s", maintenant = ts) | |
| 22 | + assertEquals("Notes du 05/09/2026 à 09:07", resultat) | |
| 23 | + } | |
| 24 | + | |
| 25 | + @Test | |
| 26 | + fun sujet_formate_date_fin_de_mois() { | |
| 27 | + val ts = horodatage(2026, 12, 31, 23, 59) | |
| 28 | + val resultat = genererSujetParDefaut("Notes du %1\$s", maintenant = ts) | |
| 29 | + assertEquals("Notes du 31/12/2026 à 23:59", resultat) | |
| 30 | + } | |
| 31 | + | |
| 32 | + @Test | |
| 33 | + fun sujet_formate_minuit() { | |
| 34 | + val ts = horodatage(2026, 1, 1, 0, 0) | |
| 35 | + val resultat = genererSujetParDefaut("Notes du %1\$s", maintenant = ts) | |
| 36 | + assertEquals("Notes du 01/01/2026 à 00:00", resultat) | |
| 37 | + } | |
| 38 | + | |
| 39 | + @Test | |
| 40 | + fun sujet_contient_date_formatee() { | |
| 41 | + val resultat = genererSujetParDefaut("Notes du %1\$s") | |
| 42 | + // Vérifie que le résultat commence par le préfixe et contient le format jj/MM/yyyy à HH:mm | |
| 43 | + assertTrue(resultat.startsWith("Notes du "), "Devrait commencer par 'Notes du '") | |
| 44 | + assertTrue( | |
| 45 | + Regex("""Notes du \d{2}/\d{2}/\d{4} à \d{2}:\d{2}""").matches(resultat), | |
| 46 | + "Format attendu : 'Notes du jj/MM/yyyy à HH:mm', obtenu : '$resultat'", | |
| 47 | + ) | |
| 48 | + } | |
| 49 | + | |
| 50 | + @Test | |
| 51 | + fun sujet_utilise_le_modele_fourni() { | |
| 52 | + val ts = horodatage(2026, 6, 15, 14, 30) | |
| 53 | + val resultat = genererSujetParDefaut("Enregistrement du %1\$s", maintenant = ts) | |
| 54 | + assertEquals("Enregistrement du 15/06/2026 à 14:30", resultat) | |
| 55 | + } | |
| 56 | +} |
A
android/app/src/test/java/fr/ebii/card2vcf/audio/TranscripteurLocalFake.kt
+27
-0
@@ -0,0 +1,27 @@
| 1 | +package fr.ebii.card2vcf.audio | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Implémentation factice de [TranscripteurLocal] pour les tests unitaires. | |
| 5 | + */ | |
| 6 | +class TranscripteurLocalFake(disponible: Boolean = true) : TranscripteurLocal { | |
| 7 | + | |
| 8 | + override val etat: TranscripteurLocal.Etat = | |
| 9 | + if (disponible) TranscripteurLocal.Etat.DISPONIBLE else TranscripteurLocal.Etat.INDISPONIBLE | |
| 10 | + | |
| 11 | + private val buffersRecus = mutableListOf<Pair<ShortArray, Int>>() | |
| 12 | + var reinitialiseAppele = false | |
| 13 | + | |
| 14 | + override fun accepterEchantillons(data: ShortArray, longueur: Int): String? { | |
| 15 | + buffersRecus += data to longueur | |
| 16 | + return null // pas de résultat partiel simulé | |
| 17 | + } | |
| 18 | + | |
| 19 | + override fun finaliser(): String = "transcription simulée" | |
| 20 | + | |
| 21 | + override fun reinitialiser() { | |
| 22 | + reinitialiseAppele = true | |
| 23 | + buffersRecus.clear() | |
| 24 | + } | |
| 25 | + | |
| 26 | + fun nombreBuffersRecus(): Int = buffersRecus.size | |
| 27 | +} |
A
android/app/src/test/java/fr/ebii/card2vcf/audio/TranscripteurVoskTest.kt
+104
-0
@@ -0,0 +1,104 @@
| 1 | +package fr.ebii.card2vcf.audio | |
| 2 | + | |
| 3 | +import kotlin.test.Test | |
| 4 | +import kotlin.test.assertEquals | |
| 5 | +import kotlin.test.assertNull | |
| 6 | +import kotlin.test.assertTrue | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Tests JVM des méthodes de parsing JSON de Vosk et du fake. | |
| 10 | + * N'instancie pas TranscripteurVosk (nécessite un Context Android + lib native). | |
| 11 | + */ | |
| 12 | +class TranscripteurVoskTest { | |
| 13 | + | |
| 14 | + // --- Parsing "partial" --- | |
| 15 | + | |
| 16 | + @Test | |
| 17 | + fun parsagePartielNormal() { | |
| 18 | + val json = """{"partial":"bonjour le"}""" | |
| 19 | + assertEquals("bonjour le", TranscripteurVosk.parsagePartiel(json)) | |
| 20 | + } | |
| 21 | + | |
| 22 | + @Test | |
| 23 | + fun parsagePartielAvecEspaces() { | |
| 24 | + val json = """{ "partial" : "comment allez vous" }""" | |
| 25 | + assertEquals("comment allez vous", TranscripteurVosk.parsagePartiel(json)) | |
| 26 | + } | |
| 27 | + | |
| 28 | + @Test | |
| 29 | + fun parsagePartielVideRetourneNull() { | |
| 30 | + val json = """{"partial":""}""" | |
| 31 | + assertNull(TranscripteurVosk.parsagePartiel(json)) | |
| 32 | + } | |
| 33 | + | |
| 34 | + @Test | |
| 35 | + fun parsagePartielJsonInvalidRetourneNull() { | |
| 36 | + assertNull(TranscripteurVosk.parsagePartiel("not json at all")) | |
| 37 | + assertNull(TranscripteurVosk.parsagePartiel("")) | |
| 38 | + assertNull(TranscripteurVosk.parsagePartiel("{}")) | |
| 39 | + } | |
| 40 | + | |
| 41 | + // --- Parsing "text" (résultat final) --- | |
| 42 | + | |
| 43 | + @Test | |
| 44 | + fun parsageTexteFinal() { | |
| 45 | + val json = """{"text":"bonjour le monde"}""" | |
| 46 | + assertEquals("bonjour le monde", TranscripteurVosk.parsageTexte(json)) | |
| 47 | + } | |
| 48 | + | |
| 49 | + @Test | |
| 50 | + fun parsageTexteVide() { | |
| 51 | + val json = """{"text":""}""" | |
| 52 | + assertEquals("", TranscripteurVosk.parsageTexte(json)) | |
| 53 | + } | |
| 54 | + | |
| 55 | + @Test | |
| 56 | + fun parsageTexteJsonInvalidRetourneVide() { | |
| 57 | + assertEquals("", TranscripteurVosk.parsageTexte("not json")) | |
| 58 | + assertEquals("", TranscripteurVosk.parsageTexte("")) | |
| 59 | + } | |
| 60 | + | |
| 61 | + @Test | |
| 62 | + fun parsageTexteAvecChampsSupplementaires() { | |
| 63 | + // Vosk peut renvoyer des champs supplémentaires (résultats détaillés) | |
| 64 | + val json = """{"result":[],"text":"merci beaucoup"}""" | |
| 65 | + assertEquals("merci beaucoup", TranscripteurVosk.parsageTexte(json)) | |
| 66 | + } | |
| 67 | + | |
| 68 | + // --- Fake --- | |
| 69 | + | |
| 70 | + @Test | |
| 71 | + fun fakeDisponibleParDefaut() { | |
| 72 | + val fake = TranscripteurLocalFake() | |
| 73 | + assertEquals(TranscripteurLocal.Etat.DISPONIBLE, fake.etat) | |
| 74 | + } | |
| 75 | + | |
| 76 | + @Test | |
| 77 | + fun fakeIndisponible() { | |
| 78 | + val fake = TranscripteurLocalFake(disponible = false) | |
| 79 | + assertEquals(TranscripteurLocal.Etat.INDISPONIBLE, fake.etat) | |
| 80 | + } | |
| 81 | + | |
| 82 | + @Test | |
| 83 | + fun fakeAccepteEchantillons() { | |
| 84 | + val fake = TranscripteurLocalFake() | |
| 85 | + assertNull(fake.accepterEchantillons(ShortArray(160), 160)) | |
| 86 | + assertNull(fake.accepterEchantillons(ShortArray(160), 160)) | |
| 87 | + assertEquals(2, fake.nombreBuffersRecus()) | |
| 88 | + } | |
| 89 | + | |
| 90 | + @Test | |
| 91 | + fun fakeFinaliserRetourneTexte() { | |
| 92 | + val fake = TranscripteurLocalFake() | |
| 93 | + assertEquals("transcription simulée", fake.finaliser()) | |
| 94 | + } | |
| 95 | + | |
| 96 | + @Test | |
| 97 | + fun fakeReinitialiser() { | |
| 98 | + val fake = TranscripteurLocalFake() | |
| 99 | + fake.accepterEchantillons(ShortArray(100), 100) | |
| 100 | + fake.reinitialiser() | |
| 101 | + assertTrue(fake.reinitialiseAppele) | |
| 102 | + assertEquals(0, fake.nombreBuffersRecus()) | |
| 103 | + } | |
| 104 | +} |
A
android/app/src/test/java/fr/ebii/card2vcf/data/AudioNoteStoreMoveTest.kt
+57
-0
@@ -0,0 +1,57 @@
| 1 | +package fr.ebii.card2vcf.data | |
| 2 | + | |
| 3 | +import java.io.File | |
| 4 | +import kotlin.test.Test | |
| 5 | +import kotlin.test.assertEquals | |
| 6 | +import kotlin.test.assertFalse | |
| 7 | +import kotlin.test.assertTrue | |
| 8 | + | |
| 9 | +class AudioNoteStoreMoveTest { | |
| 10 | + | |
| 11 | + @Test | |
| 12 | + fun deplacement_copie_contenu_et_supprime_source() { | |
| 13 | + val source = File.createTempFile("source_move", ".wav") | |
| 14 | + val cible = File.createTempFile("cible_move", ".wav").also { it.delete() } | |
| 15 | + try { | |
| 16 | + source.writeBytes(byteArrayOf(1, 2, 3, 4)) | |
| 17 | + val chemin = deplacerFichierAudio(source, cible) | |
| 18 | + assertEquals(cible.absolutePath, chemin) | |
| 19 | + assertTrue(cible.exists(), "La cible doit exister après le déplacement") | |
| 20 | + assertEquals(4, cible.length().toInt(), "La cible doit avoir le même contenu") | |
| 21 | + assertFalse(source.exists(), "La source doit être supprimée") | |
| 22 | + } finally { | |
| 23 | + cible.delete() | |
| 24 | + } | |
| 25 | + } | |
| 26 | + | |
| 27 | + @Test | |
| 28 | + fun deplacement_cree_repertoire_parent() { | |
| 29 | + val source = File.createTempFile("source_mkdir", ".wav") | |
| 30 | + val dir = File(System.getProperty("java.io.tmpdir"), "test_audio_move_${System.currentTimeMillis()}") | |
| 31 | + val cible = File(dir, "audio.wav") | |
| 32 | + try { | |
| 33 | + source.writeBytes(byteArrayOf(5, 6, 7)) | |
| 34 | + deplacerFichierAudio(source, cible) | |
| 35 | + assertTrue(cible.exists(), "La cible doit exister même si le dossier parent n'existait pas") | |
| 36 | + assertEquals(3, cible.length().toInt()) | |
| 37 | + } finally { | |
| 38 | + cible.delete() | |
| 39 | + dir.delete() | |
| 40 | + } | |
| 41 | + } | |
| 42 | + | |
| 43 | + @Test | |
| 44 | + fun deplacement_ecrase_cible_existante() { | |
| 45 | + val source = File.createTempFile("source_overwrite", ".wav") | |
| 46 | + val cible = File.createTempFile("cible_overwrite", ".wav") | |
| 47 | + try { | |
| 48 | + source.writeBytes(byteArrayOf(10, 20)) | |
| 49 | + cible.writeBytes(byteArrayOf(99, 99, 99)) | |
| 50 | + deplacerFichierAudio(source, cible) | |
| 51 | + assertEquals(2, cible.length().toInt(), "La cible doit contenir les octets de la source") | |
| 52 | + assertFalse(source.exists()) | |
| 53 | + } finally { | |
| 54 | + cible.delete() | |
| 55 | + } | |
| 56 | + } | |
| 57 | +} |
M
android/app/src/test/java/fr/ebii/card2vcf/sync/FakeAilianceApi.kt
+90
-1
@@ -58,7 +58,39 @@ class FakeAilianceApi : AilianceApi {
| 58 | 58 | override fun updateTache(projetId: String, tacheId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}") |
| 59 | 59 | override fun deleteTache(projetId: String, tacheId: String) = AilianceApiClient.ApiResult.Ok(Unit) |
| 60 | 60 | override fun moveTache(projetId: String, tacheId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}") |
| 61 | - override fun createInteraction(contactId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}") | |
| 61 | + var createInteractionResult: AilianceApiClient.ApiResult<String> = | |
| 62 | + AilianceApiClient.ApiResult.Ok("""{"id":"interaction-generated"}""") | |
| 63 | + val createInteractionCalls = mutableListOf<Pair<String, String>>() // contactId, jsonBody | |
| 64 | + | |
| 65 | + override fun createInteraction(contactId: String, jsonBody: String): AilianceApiClient.ApiResult<String> { | |
| 66 | + createInteractionCalls += contactId to jsonBody | |
| 67 | + return createInteractionResult | |
| 68 | + } | |
| 69 | + | |
| 70 | + var deleteInteractionResult: AilianceApiClient.ApiResult<Unit> = AilianceApiClient.ApiResult.Ok(Unit) | |
| 71 | + val deleteInteractionCalls = mutableListOf<String>() // iid | |
| 72 | + | |
| 73 | + override fun deleteInteraction(iid: String): AilianceApiClient.ApiResult<Unit> { | |
| 74 | + deleteInteractionCalls += iid | |
| 75 | + return deleteInteractionResult | |
| 76 | + } | |
| 77 | + | |
| 78 | + var relancerTranscriptionInteractionResult: AilianceApiClient.ApiResult<Unit> = AilianceApiClient.ApiResult.Ok(Unit) | |
| 79 | + val relancerTranscriptionInteractionCalls = mutableListOf<Pair<String, String>>() // contactServerId, iid | |
| 80 | + | |
| 81 | + override fun relancerTranscriptionInteraction(contactServerId: String, iid: String): AilianceApiClient.ApiResult<Unit> { | |
| 82 | + relancerTranscriptionInteractionCalls += contactServerId to iid | |
| 83 | + return relancerTranscriptionInteractionResult | |
| 84 | + } | |
| 85 | + | |
| 86 | + var relancerTranscriptionNoteResult: AilianceApiClient.ApiResult<Unit> = AilianceApiClient.ApiResult.Ok(Unit) | |
| 87 | + val relancerTranscriptionNoteCalls = mutableListOf<Pair<String, String>>() // projetServerId, nid | |
| 88 | + | |
| 89 | + override fun relancerTranscriptionNote(projetServerId: String, nid: String): AilianceApiClient.ApiResult<Unit> { | |
| 90 | + relancerTranscriptionNoteCalls += projetServerId to nid | |
| 91 | + return relancerTranscriptionNoteResult | |
| 92 | + } | |
| 93 | + | |
| 62 | 94 | override fun listRdv() = AilianceApiClient.ApiResult.Ok(emptyList<RendezVousDto>()) |
| 63 | 95 | |
| 64 | 96 | override fun createRdv(jsonBody: String): AilianceApiClient.ApiResult<String> { |
@@ -129,4 +161,61 @@ class FakeAilianceApi : AilianceApi {
| 129 | 161 | downloadPhotoCalls += id |
| 130 | 162 | return downloadPhotoResult |
| 131 | 163 | } |
| 164 | + | |
| 165 | + var uploadInteractionAudioResult: AilianceApiClient.ApiResult<String> = AilianceApiClient.ApiResult.Ok("{}") | |
| 166 | + val uploadInteractionAudioCalls = mutableListOf<Triple<String, String, Int>>() // contactId, iid, bytes size | |
| 167 | + | |
| 168 | + override fun uploadInteractionAudio( | |
| 169 | + contactId: String, | |
| 170 | + iid: String, | |
| 171 | + bytes: ByteArray, | |
| 172 | + filename: String, | |
| 173 | + contentType: String, | |
| 174 | + ): AilianceApiClient.ApiResult<String> { | |
| 175 | + uploadInteractionAudioCalls += Triple(contactId, iid, bytes.size) | |
| 176 | + return uploadInteractionAudioResult | |
| 177 | + } | |
| 178 | + | |
| 179 | + override fun downloadInteractionAudio(contactId: String, iid: String): AilianceApiClient.ApiResult<ByteArray> = | |
| 180 | + AilianceApiClient.ApiResult.Err(404, "absent") | |
| 181 | + | |
| 182 | + var createNoteProjetResult: AilianceApiClient.ApiResult<String> = | |
| 183 | + AilianceApiClient.ApiResult.Ok("""{"id":"note-generated"}""") | |
| 184 | + val createNoteProjetCalls = mutableListOf<Pair<String, String>>() // projetId, jsonBody | |
| 185 | + | |
| 186 | + override fun createNoteProjet(projetId: String, jsonBody: String): AilianceApiClient.ApiResult<String> { | |
| 187 | + createNoteProjetCalls += projetId to jsonBody | |
| 188 | + return createNoteProjetResult | |
| 189 | + } | |
| 190 | + | |
| 191 | + val updateNoteProjetCalls = mutableListOf<Triple<String, String, String>>() // projetId, nid, jsonBody | |
| 192 | + | |
| 193 | + override fun updateNoteProjet(projetId: String, nid: String, jsonBody: String): AilianceApiClient.ApiResult<String> { | |
| 194 | + updateNoteProjetCalls += Triple(projetId, nid, jsonBody) | |
| 195 | + return AilianceApiClient.ApiResult.Ok("{}") | |
| 196 | + } | |
| 197 | + | |
| 198 | + val deleteNoteProjetCalls = mutableListOf<Pair<String, String>>() // projetId, nid | |
| 199 | + | |
| 200 | + override fun deleteNoteProjet(projetId: String, nid: String): AilianceApiClient.ApiResult<Unit> { | |
| 201 | + deleteNoteProjetCalls += projetId to nid | |
| 202 | + return AilianceApiClient.ApiResult.Ok(Unit) | |
| 203 | + } | |
| 204 | + | |
| 205 | + var uploadNoteAudioResult: AilianceApiClient.ApiResult<String> = AilianceApiClient.ApiResult.Ok("{}") | |
| 206 | + val uploadNoteAudioCalls = mutableListOf<Triple<String, String, Int>>() // projetId, nid, bytes size | |
| 207 | + | |
| 208 | + override fun uploadNoteAudio( | |
| 209 | + projetId: String, | |
| 210 | + nid: String, | |
| 211 | + bytes: ByteArray, | |
| 212 | + filename: String, | |
| 213 | + contentType: String, | |
| 214 | + ): AilianceApiClient.ApiResult<String> { | |
| 215 | + uploadNoteAudioCalls += Triple(projetId, nid, bytes.size) | |
| 216 | + return uploadNoteAudioResult | |
| 217 | + } | |
| 218 | + | |
| 219 | + override fun downloadNoteAudio(projetId: String, nid: String): AilianceApiClient.ApiResult<ByteArray> = | |
| 220 | + AilianceApiClient.ApiResult.Err(404, "absent") | |
| 132 | 221 | } |
A
android/app/src/test/java/fr/ebii/card2vcf/sync/InteractionVocaleTest.kt
+410
-0
@@ -0,0 +1,410 @@
| 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.InteractionEntity | |
| 8 | +import fr.ebii.card2vcf.data.NoteProjetEntity | |
| 9 | +import kotlinx.coroutines.runBlocking | |
| 10 | +import org.junit.After | |
| 11 | +import org.junit.Assert.assertEquals | |
| 12 | +import org.junit.Assert.assertNotNull | |
| 13 | +import org.junit.Assert.assertNull | |
| 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.io.File | |
| 20 | + | |
| 21 | +@RunWith(RobolectricTestRunner::class) | |
| 22 | +@Config(sdk = [31]) | |
| 23 | +class InteractionVocaleTest { | |
| 24 | + | |
| 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 | + // ---- Push interaction note_vocale ---- | |
| 43 | + | |
| 44 | + @Test | |
| 45 | + fun push_interactionNoteVocale_createPuisUploadEtServerIdEnregistre() = runBlocking { | |
| 46 | + // Prépare un fichier audio local | |
| 47 | + val audioFile = File.createTempFile("note_vocale", ".wav") | |
| 48 | + audioFile.writeBytes(byteArrayOf(1, 2, 3, 4, 5)) | |
| 49 | + | |
| 50 | + // Interaction locale avec audioPath | |
| 51 | + val localId = db.interactionDao().upsert( | |
| 52 | + InteractionEntity( | |
| 53 | + contactServerId = "contact-srv-1", | |
| 54 | + type = "note_vocale", | |
| 55 | + sujet = "Note vocale", | |
| 56 | + audioPath = audioFile.absolutePath, | |
| 57 | + ), | |
| 58 | + ) | |
| 59 | + // Op de create dans la file de sync (serverId = contactServerId) | |
| 60 | + db.syncOpDao().insert( | |
| 61 | + SyncOpEntity( | |
| 62 | + entityType = "interaction", | |
| 63 | + op = "create", | |
| 64 | + payloadJson = """{"sujet":"Note vocale","type_interaction":"note_vocale","demande_transcription":true}""", | |
| 65 | + localId = localId, | |
| 66 | + serverId = "contact-srv-1", | |
| 67 | + createdAt = 1L, | |
| 68 | + ), | |
| 69 | + ) | |
| 70 | + | |
| 71 | + engine.syncNow() | |
| 72 | + | |
| 73 | + // L'op create a été appelée | |
| 74 | + assertEquals(1, api.createInteractionCalls.size) | |
| 75 | + assertEquals("contact-srv-1", api.createInteractionCalls[0].first) | |
| 76 | + | |
| 77 | + // L'upload audio a été appelé | |
| 78 | + assertEquals(1, api.uploadInteractionAudioCalls.size) | |
| 79 | + assertEquals("contact-srv-1", api.uploadInteractionAudioCalls[0].first) | |
| 80 | + assertEquals("interaction-generated", api.uploadInteractionAudioCalls[0].second) | |
| 81 | + assertEquals(5, api.uploadInteractionAudioCalls[0].third) | |
| 82 | + | |
| 83 | + // Le serverId a été enregistré sur l'entité locale | |
| 84 | + val updated = db.interactionDao().getByLocalId(localId) | |
| 85 | + assertEquals("interaction-generated", updated?.serverId) | |
| 86 | + | |
| 87 | + // L'op a été dépilée (upload OK) | |
| 88 | + assertEquals(0, db.syncOpDao().listAll().size) | |
| 89 | + | |
| 90 | + audioFile.deleteOnExit() | |
| 91 | + } | |
| 92 | + | |
| 93 | + @Test | |
| 94 | + fun push_interactionNoteVocale_echecUpload_opMediaEnFile() = runBlocking { | |
| 95 | + val audioFile = File.createTempFile("note_vocale", ".wav") | |
| 96 | + audioFile.writeBytes(byteArrayOf(1, 2, 3)) | |
| 97 | + api.uploadInteractionAudioResult = AilianceApiClient.ApiResult.Err(503, "Service indisponible") | |
| 98 | + | |
| 99 | + val localId = db.interactionDao().upsert( | |
| 100 | + InteractionEntity( | |
| 101 | + contactServerId = "contact-srv-2", | |
| 102 | + type = "note_vocale", | |
| 103 | + sujet = "Note avec erreur upload", | |
| 104 | + audioPath = audioFile.absolutePath, | |
| 105 | + ), | |
| 106 | + ) | |
| 107 | + db.syncOpDao().insert( | |
| 108 | + SyncOpEntity( | |
| 109 | + entityType = "interaction", | |
| 110 | + op = "create", | |
| 111 | + payloadJson = """{"sujet":"Note avec erreur upload","type_interaction":"note_vocale","demande_transcription":true}""", | |
| 112 | + localId = localId, | |
| 113 | + serverId = "contact-srv-2", | |
| 114 | + createdAt = 1L, | |
| 115 | + ), | |
| 116 | + ) | |
| 117 | + | |
| 118 | + engine.syncNow() | |
| 119 | + | |
| 120 | + // L'op create a été dépilée (create OK) | |
| 121 | + val remainingOps = db.syncOpDao().listAll() | |
| 122 | + // Une op media doit être en file pour retry | |
| 123 | + assertEquals(1, remainingOps.size) | |
| 124 | + assertEquals("interaction_media", remainingOps[0].entityType) | |
| 125 | + assertEquals("upload", remainingOps[0].op) | |
| 126 | + assertEquals(localId, remainingOps[0].localId) | |
| 127 | + assertEquals("interaction-generated", remainingOps[0].serverId) | |
| 128 | + | |
| 129 | + audioFile.deleteOnExit() | |
| 130 | + } | |
| 131 | + | |
| 132 | + // ---- Pull interaction LWW ---- | |
| 133 | + | |
| 134 | + @Test | |
| 135 | + fun pull_interactionAvecMisAJourLeRecent_descriptionMisAJourAudioPathPreserve() = runBlocking { | |
| 136 | + val localAudioPath = "/data/audio/interaction_locale.wav" | |
| 137 | + db.interactionDao().upsert( | |
| 138 | + InteractionEntity( | |
| 139 | + serverId = "int-srv-1", | |
| 140 | + contactServerId = "contact-srv-1", | |
| 141 | + type = "note_vocale", | |
| 142 | + sujet = "Ancien sujet", | |
| 143 | + description = "Ancienne description", | |
| 144 | + audioPath = localAudioPath, | |
| 145 | + createdAt = parseIsoToEpochMs("2026-09-10T08:00:00Z"), | |
| 146 | + updatedAt = parseIsoToEpochMs("2026-09-10T08:00:00Z"), | |
| 147 | + ), | |
| 148 | + ) | |
| 149 | + | |
| 150 | + api.pullResult = AilianceApiClient.ApiResult.Ok( | |
| 151 | + SyncPullResponse( | |
| 152 | + serverTime = "2026-09-15T10:00:00Z", | |
| 153 | + interactions = listOf( | |
| 154 | + InteractionDto( | |
| 155 | + id = "int-srv-1", | |
| 156 | + contactId = "contact-srv-1", | |
| 157 | + typeInteraction = "note_vocale", | |
| 158 | + sujet = "Sujet mis à jour", | |
| 159 | + description = "Description mise à jour", | |
| 160 | + creeLe = "2026-09-10T08:00:00Z", | |
| 161 | + misAJourLe = "2026-09-15T09:00:00Z", // plus récent que updatedAt local | |
| 162 | + transcription = "terminee", | |
| 163 | + ), | |
| 164 | + ), | |
| 165 | + ), | |
| 166 | + ) | |
| 167 | + | |
| 168 | + engine.syncNow() | |
| 169 | + | |
| 170 | + val updated = db.interactionDao().getByServerId("int-srv-1") | |
| 171 | + assertNotNull(updated) | |
| 172 | + assertEquals("Sujet mis à jour", updated?.sujet) | |
| 173 | + assertEquals("Description mise à jour", updated?.description) | |
| 174 | + assertEquals("terminee", updated?.transcriptionStatut) | |
| 175 | + // audioPath local préservé | |
| 176 | + assertEquals(localAudioPath, updated?.audioPath) | |
| 177 | + } | |
| 178 | + | |
| 179 | + @Test | |
| 180 | + fun pull_interactionAvecMisAJourLeAncien_intact() = runBlocking { | |
| 181 | + val sujetOriginal = "Sujet original" | |
| 182 | + db.interactionDao().upsert( | |
| 183 | + InteractionEntity( | |
| 184 | + serverId = "int-srv-2", | |
| 185 | + contactServerId = "contact-srv-1", | |
| 186 | + type = "note", | |
| 187 | + sujet = sujetOriginal, | |
| 188 | + description = "Description originale", | |
| 189 | + createdAt = parseIsoToEpochMs("2026-09-14T08:00:00Z"), | |
| 190 | + updatedAt = parseIsoToEpochMs("2026-09-15T09:00:00Z"), // local plus récent | |
| 191 | + ), | |
| 192 | + ) | |
| 193 | + | |
| 194 | + api.pullResult = AilianceApiClient.ApiResult.Ok( | |
| 195 | + SyncPullResponse( | |
| 196 | + serverTime = "2026-09-15T10:00:00Z", | |
| 197 | + interactions = listOf( | |
| 198 | + InteractionDto( | |
| 199 | + id = "int-srv-2", | |
| 200 | + contactId = "contact-srv-1", | |
| 201 | + typeInteraction = "note", | |
| 202 | + sujet = "Sujet distant obsolète", | |
| 203 | + description = "Description obsolète", | |
| 204 | + creeLe = "2026-09-14T08:00:00Z", | |
| 205 | + misAJourLe = "2026-09-14T10:00:00Z", // plus ancien que updatedAt local | |
| 206 | + ), | |
| 207 | + ), | |
| 208 | + ), | |
| 209 | + ) | |
| 210 | + | |
| 211 | + engine.syncNow() | |
| 212 | + | |
| 213 | + val unchanged = db.interactionDao().getByServerId("int-srv-2") | |
| 214 | + assertEquals(sujetOriginal, unchanged?.sujet) | |
| 215 | + } | |
| 216 | + | |
| 217 | + // ---- Push note_projet create/update/delete ---- | |
| 218 | + | |
| 219 | + @Test | |
| 220 | + fun push_noteProjetCreate_serverIdEnregistre() = runBlocking { | |
| 221 | + val localId = db.noteProjetDao().upsert( | |
| 222 | + NoteProjetEntity( | |
| 223 | + projetServerId = "projet-srv-1", | |
| 224 | + titre = "Ma note", | |
| 225 | + texte = "Contenu", | |
| 226 | + auteur = "alice", | |
| 227 | + createdAt = 1L, | |
| 228 | + ), | |
| 229 | + ) | |
| 230 | + db.syncOpDao().insert( | |
| 231 | + SyncOpEntity( | |
| 232 | + entityType = "note_projet", | |
| 233 | + op = "create", | |
| 234 | + payloadJson = """{"titre":"Ma note","contenu":"Contenu","demande_transcription":false}""", | |
| 235 | + localId = localId, | |
| 236 | + createdAt = 1L, | |
| 237 | + ), | |
| 238 | + ) | |
| 239 | + | |
| 240 | + engine.syncNow() | |
| 241 | + | |
| 242 | + assertEquals(1, api.createNoteProjetCalls.size) | |
| 243 | + assertEquals("projet-srv-1", api.createNoteProjetCalls[0].first) | |
| 244 | + val updated = db.noteProjetDao().getByLocalId(localId) | |
| 245 | + assertEquals("note-generated", updated?.serverId) | |
| 246 | + assertEquals(0, db.syncOpDao().listAll().size) | |
| 247 | + } | |
| 248 | + | |
| 249 | + @Test | |
| 250 | + fun push_noteProjetUpdate_appelApi() = runBlocking { | |
| 251 | + db.noteProjetDao().upsert( | |
| 252 | + NoteProjetEntity( | |
| 253 | + serverId = "note-srv-1", | |
| 254 | + projetServerId = "projet-srv-1", | |
| 255 | + titre = "Titre modifié", | |
| 256 | + texte = "Contenu modifié", | |
| 257 | + auteur = "alice", | |
| 258 | + createdAt = 1L, | |
| 259 | + ), | |
| 260 | + ) | |
| 261 | + db.syncOpDao().insert( | |
| 262 | + SyncOpEntity( | |
| 263 | + entityType = "note_projet", | |
| 264 | + op = "update", | |
| 265 | + payloadJson = """{"titre":"Titre modifié","contenu":"Contenu modifié"}""", | |
| 266 | + serverId = "note-srv-1", | |
| 267 | + createdAt = 1L, | |
| 268 | + ), | |
| 269 | + ) | |
| 270 | + | |
| 271 | + engine.syncNow() | |
| 272 | + | |
| 273 | + assertEquals(1, api.updateNoteProjetCalls.size) | |
| 274 | + assertEquals("projet-srv-1", api.updateNoteProjetCalls[0].first) | |
| 275 | + assertEquals("note-srv-1", api.updateNoteProjetCalls[0].second) | |
| 276 | + assertEquals(0, db.syncOpDao().listAll().size) | |
| 277 | + } | |
| 278 | + | |
| 279 | + @Test | |
| 280 | + fun push_noteProjetDelete_appelApi() = runBlocking { | |
| 281 | + db.noteProjetDao().upsert( | |
| 282 | + NoteProjetEntity( | |
| 283 | + serverId = "note-srv-2", | |
| 284 | + projetServerId = "projet-srv-2", | |
| 285 | + titre = "À supprimer", | |
| 286 | + texte = "Contenu", | |
| 287 | + auteur = "alice", | |
| 288 | + createdAt = 1L, | |
| 289 | + ), | |
| 290 | + ) | |
| 291 | + db.syncOpDao().insert( | |
| 292 | + SyncOpEntity( | |
| 293 | + entityType = "note_projet", | |
| 294 | + op = "delete", | |
| 295 | + payloadJson = "{}", | |
| 296 | + serverId = "note-srv-2", | |
| 297 | + createdAt = 1L, | |
| 298 | + ), | |
| 299 | + ) | |
| 300 | + | |
| 301 | + engine.syncNow() | |
| 302 | + | |
| 303 | + assertEquals(1, api.deleteNoteProjetCalls.size) | |
| 304 | + assertEquals("projet-srv-2", api.deleteNoteProjetCalls[0].first) | |
| 305 | + assertEquals("note-srv-2", api.deleteNoteProjetCalls[0].second) | |
| 306 | + assertEquals(0, db.syncOpDao().listAll().size) | |
| 307 | + } | |
| 308 | + | |
| 309 | + @Test | |
| 310 | + fun pull_tombstoneInteraction_suppression() = runBlocking { | |
| 311 | + db.interactionDao().upsert( | |
| 312 | + InteractionEntity( | |
| 313 | + serverId = "int-delete", | |
| 314 | + contactServerId = "contact-srv-1", | |
| 315 | + type = "note", | |
| 316 | + sujet = "À supprimer", | |
| 317 | + createdAt = parseIsoToEpochMs("2026-09-10T08:00:00Z"), | |
| 318 | + ), | |
| 319 | + ) | |
| 320 | + api.pullResult = AilianceApiClient.ApiResult.Ok( | |
| 321 | + SyncPullResponse( | |
| 322 | + serverTime = "2026-09-15T10:00:00Z", | |
| 323 | + tombstones = listOf( | |
| 324 | + TombstoneDto( | |
| 325 | + entityType = "interaction", | |
| 326 | + id = "int-delete", | |
| 327 | + supprimeLe = "2026-09-15T09:00:00Z", | |
| 328 | + ), | |
| 329 | + ), | |
| 330 | + ), | |
| 331 | + ) | |
| 332 | + | |
| 333 | + engine.syncNow() | |
| 334 | + | |
| 335 | + assertNull(db.interactionDao().getByServerId("int-delete")) | |
| 336 | + } | |
| 337 | + | |
| 338 | + // ---- pousserEnAttente ---- | |
| 339 | + | |
| 340 | + @Test | |
| 341 | + fun pousserEnAttente_modeServeur_pushDeclencheEtOpConsommee() = runBlocking { | |
| 342 | + val localId = db.interactionDao().upsert( | |
| 343 | + InteractionEntity( | |
| 344 | + contactServerId = "contact-srv-1", | |
| 345 | + type = "note_vocale", | |
| 346 | + sujet = "Note rapide", | |
| 347 | + transcriptionStatut = "en_attente", | |
| 348 | + createdAt = 1L, | |
| 349 | + ), | |
| 350 | + ) | |
| 351 | + db.syncOpDao().insert( | |
| 352 | + SyncOpEntity( | |
| 353 | + entityType = "interaction", | |
| 354 | + op = "create", | |
| 355 | + payloadJson = """{"sujet":"Note rapide","type_interaction":"note_vocale","demande_transcription":true}""", | |
| 356 | + localId = localId, | |
| 357 | + serverId = "contact-srv-1", | |
| 358 | + createdAt = 1L, | |
| 359 | + ), | |
| 360 | + ) | |
| 361 | + | |
| 362 | + val rapport = engine.pousserEnAttente() | |
| 363 | + | |
| 364 | + assertEquals(1, api.createInteractionCalls.size) | |
| 365 | + assertEquals(1, rapport.envoyes) | |
| 366 | + assertEquals(0, rapport.echecs) | |
| 367 | + assertEquals(0, db.syncOpDao().listAll().size) | |
| 368 | + } | |
| 369 | + | |
| 370 | + @Test | |
| 371 | + fun pousserEnAttente_echecReseau_opConserveeEnFile() = runBlocking { | |
| 372 | + api.createInteractionResult = AilianceApiClient.ApiResult.Err(503, "Service indisponible") | |
| 373 | + val localId = db.interactionDao().upsert( | |
| 374 | + InteractionEntity( | |
| 375 | + contactServerId = "contact-srv-1", | |
| 376 | + type = "note_vocale", | |
| 377 | + sujet = "Note réseau KO", | |
| 378 | + transcriptionStatut = "en_attente", | |
| 379 | + createdAt = 1L, | |
| 380 | + ), | |
| 381 | + ) | |
| 382 | + db.syncOpDao().insert( | |
| 383 | + SyncOpEntity( | |
| 384 | + entityType = "interaction", | |
| 385 | + op = "create", | |
| 386 | + payloadJson = """{"sujet":"Note réseau KO","type_interaction":"note_vocale","demande_transcription":true}""", | |
| 387 | + localId = localId, | |
| 388 | + serverId = "contact-srv-1", | |
| 389 | + createdAt = 1L, | |
| 390 | + ), | |
| 391 | + ) | |
| 392 | + | |
| 393 | + val rapport = engine.pousserEnAttente() | |
| 394 | + | |
| 395 | + assertEquals(0, rapport.envoyes) | |
| 396 | + assertEquals(1, rapport.echecs) | |
| 397 | + val ops = db.syncOpDao().listAll() | |
| 398 | + assertEquals(1, ops.size) | |
| 399 | + assertEquals("interaction", ops[0].entityType) | |
| 400 | + } | |
| 401 | + | |
| 402 | + @Test | |
| 403 | + fun pousserEnAttente_fileVide_aucunAppelApi() = runBlocking { | |
| 404 | + val rapport = engine.pousserEnAttente() | |
| 405 | + | |
| 406 | + assertEquals(0, api.createInteractionCalls.size) | |
| 407 | + assertEquals(0, rapport.envoyes) | |
| 408 | + assertEquals(0, rapport.echecs) | |
| 409 | + } | |
| 410 | +} |
M
android/app/src/test/java/fr/ebii/card2vcf/sync/LwwMergerTest.kt
+16
-0
@@ -108,6 +108,22 @@ class LwwMergerTest {
| 108 | 108 | } |
| 109 | 109 | |
| 110 | 110 | @Test |
| 111 | + fun shouldUpdateInteraction_whenRemoteStrictlyNewer() { | |
| 112 | + assertTrue(LwwMerger.shouldUpdateInteraction(localTs = 100L, remoteTs = 200L)) | |
| 113 | + } | |
| 114 | + | |
| 115 | + @Test | |
| 116 | + fun shouldUpdateInteraction_whenLocalNewer() { | |
| 117 | + assertFalse(LwwMerger.shouldUpdateInteraction(localTs = 300L, remoteTs = 200L)) | |
| 118 | + } | |
| 119 | + | |
| 120 | + @Test | |
| 121 | + fun shouldUpdateInteraction_whenEqual() { | |
| 122 | + // Pas de maj si timestamps égaux (contrairement à pickLww qui préfère le distant) | |
| 123 | + assertFalse(LwwMerger.shouldUpdateInteraction(localTs = 100L, remoteTs = 100L)) | |
| 124 | + } | |
| 125 | + | |
| 126 | + @Test | |
| 111 | 127 | fun applyTombstoneIds_removesMatchingIds() { |
| 112 | 128 | val local = setOf("a", "b", "c") |
| 113 | 129 | val tombstones = setOf("b", "d") |
A
android/app/src/test/java/fr/ebii/card2vcf/sync/NoteProjetSyncEngineTest.kt
+213
-0
@@ -0,0 +1,213 @@
| 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.NoteProjetEntity | |
| 8 | +import kotlinx.coroutines.runBlocking | |
| 9 | +import org.junit.After | |
| 10 | +import org.junit.Assert.assertEquals | |
| 11 | +import org.junit.Assert.assertNotNull | |
| 12 | +import org.junit.Assert.assertNull | |
| 13 | +import org.junit.Before | |
| 14 | +import org.junit.Test | |
| 15 | +import org.junit.runner.RunWith | |
| 16 | +import org.robolectric.RobolectricTestRunner | |
| 17 | +import org.robolectric.annotation.Config | |
| 18 | + | |
| 19 | +@RunWith(RobolectricTestRunner::class) | |
| 20 | +@Config(sdk = [31]) | |
| 21 | +class NoteProjetSyncEngineTest { | |
| 22 | + | |
| 23 | + private lateinit var db: CrmDatabase | |
| 24 | + private lateinit var api: FakeAilianceApi | |
| 25 | + private lateinit var engine: SyncEngine | |
| 26 | + | |
| 27 | + @Before | |
| 28 | + fun setUp() { | |
| 29 | + val ctx = ApplicationProvider.getApplicationContext<Context>() | |
| 30 | + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java) | |
| 31 | + .allowMainThreadQueries() | |
| 32 | + .build() | |
| 33 | + api = FakeAilianceApi() | |
| 34 | + engine = SyncEngine(api, db) | |
| 35 | + } | |
| 36 | + | |
| 37 | + @After | |
| 38 | + fun tearDown() = db.close() | |
| 39 | + | |
| 40 | + // ---- Tâche 1 : pull avec notes → insérées ---- | |
| 41 | + | |
| 42 | + @Test | |
| 43 | + fun pull_notesInserees() = runBlocking { | |
| 44 | + api.pullResult = AilianceApiClient.ApiResult.Ok( | |
| 45 | + SyncPullResponse( | |
| 46 | + serverTime = "2026-09-15T10:00:00Z", | |
| 47 | + notes = listOf( | |
| 48 | + NoteProjetDto( | |
| 49 | + id = "n1", | |
| 50 | + projetId = "p1", | |
| 51 | + titre = "Réunion kick-off", | |
| 52 | + contenu = "**Objectif** : lancer le projet.", | |
| 53 | + auteur = "alice", | |
| 54 | + creeLe = "2026-09-14T08:00:00Z", | |
| 55 | + majLe = null, | |
| 56 | + ), | |
| 57 | + NoteProjetDto( | |
| 58 | + id = "n2", | |
| 59 | + projetId = "p1", | |
| 60 | + titre = "Suivi hebdo", | |
| 61 | + contenu = "Point de la semaine.", | |
| 62 | + auteur = "bob", | |
| 63 | + creeLe = "2026-09-15T09:00:00Z", | |
| 64 | + ), | |
| 65 | + ), | |
| 66 | + ), | |
| 67 | + ) | |
| 68 | + | |
| 69 | + val result = engine.syncNow() | |
| 70 | + | |
| 71 | + assertEquals(2, db.noteProjetDao().listAll().size) | |
| 72 | + val n1 = db.noteProjetDao().getByServerId("n1") | |
| 73 | + assertNotNull(n1) | |
| 74 | + assertEquals("Réunion kick-off", n1?.titre) | |
| 75 | + assertEquals("**Objectif** : lancer le projet.", n1?.texte) | |
| 76 | + assertEquals("alice", n1?.auteur) | |
| 77 | + assertEquals("p1", n1?.projetServerId) | |
| 78 | + // Notes comptées dans received | |
| 79 | + assertEquals(2, result.received) | |
| 80 | + } | |
| 81 | + | |
| 82 | + // ---- Tâche 2 : majLe plus récent → mise à jour, audioPath local préservé ---- | |
| 83 | + | |
| 84 | + @Test | |
| 85 | + fun pull_noteExistante_majLeRecent_miseAJourEtAudioPathPreserve() = runBlocking { | |
| 86 | + val localAudioPath = "/data/app/audio/n1.wav" | |
| 87 | + db.noteProjetDao().upsert( | |
| 88 | + NoteProjetEntity( | |
| 89 | + serverId = "n1", | |
| 90 | + projetServerId = "p1", | |
| 91 | + titre = "Ancien titre", | |
| 92 | + texte = "Ancien contenu", | |
| 93 | + auteur = "alice", | |
| 94 | + audioPath = localAudioPath, | |
| 95 | + createdAt = parseIsoToEpochMs("2026-09-14T08:00:00Z"), | |
| 96 | + updatedAt = parseIsoToEpochMs("2026-09-14T08:00:00Z"), | |
| 97 | + ), | |
| 98 | + ) | |
| 99 | + | |
| 100 | + api.pullResult = AilianceApiClient.ApiResult.Ok( | |
| 101 | + SyncPullResponse( | |
| 102 | + serverTime = "2026-09-15T10:00:00Z", | |
| 103 | + notes = listOf( | |
| 104 | + NoteProjetDto( | |
| 105 | + id = "n1", | |
| 106 | + projetId = "p1", | |
| 107 | + titre = "Nouveau titre", | |
| 108 | + contenu = "Contenu mis à jour.", | |
| 109 | + auteur = "alice", | |
| 110 | + creeLe = "2026-09-14T08:00:00Z", | |
| 111 | + majLe = "2026-09-15T09:00:00Z", // plus récent que updatedAt local | |
| 112 | + ), | |
| 113 | + ), | |
| 114 | + ), | |
| 115 | + ) | |
| 116 | + | |
| 117 | + engine.syncNow() | |
| 118 | + | |
| 119 | + val updated = db.noteProjetDao().getByServerId("n1") | |
| 120 | + assertNotNull(updated) | |
| 121 | + assertEquals("Nouveau titre", updated?.titre) | |
| 122 | + assertEquals("Contenu mis à jour.", updated?.texte) | |
| 123 | + // audioPath local préservé | |
| 124 | + assertEquals(localAudioPath, updated?.audioPath) | |
| 125 | + } | |
| 126 | + | |
| 127 | + // ---- Tâche 3 : majLe plus ancien → pas d'écrasement ---- | |
| 128 | + | |
| 129 | + @Test | |
| 130 | + fun pull_noteExistante_majLeAncien_pasDecrasement() = runBlocking { | |
| 131 | + val titreOriginal = "Titre original" | |
| 132 | + db.noteProjetDao().upsert( | |
| 133 | + NoteProjetEntity( | |
| 134 | + serverId = "n1", | |
| 135 | + projetServerId = "p1", | |
| 136 | + titre = titreOriginal, | |
| 137 | + texte = "Contenu original", | |
| 138 | + auteur = "alice", | |
| 139 | + createdAt = parseIsoToEpochMs("2026-09-14T08:00:00Z"), | |
| 140 | + updatedAt = parseIsoToEpochMs("2026-09-15T09:00:00Z"), // local plus récent | |
| 141 | + ), | |
| 142 | + ) | |
| 143 | + | |
| 144 | + api.pullResult = AilianceApiClient.ApiResult.Ok( | |
| 145 | + SyncPullResponse( | |
| 146 | + serverTime = "2026-09-15T10:00:00Z", | |
| 147 | + notes = listOf( | |
| 148 | + NoteProjetDto( | |
| 149 | + id = "n1", | |
| 150 | + projetId = "p1", | |
| 151 | + titre = "Titre distant obsolète", | |
| 152 | + contenu = "Contenu distant obsolète", | |
| 153 | + auteur = "alice", | |
| 154 | + creeLe = "2026-09-14T08:00:00Z", | |
| 155 | + majLe = "2026-09-14T10:00:00Z", // plus ancien que updatedAt local | |
| 156 | + ), | |
| 157 | + ), | |
| 158 | + ), | |
| 159 | + ) | |
| 160 | + | |
| 161 | + engine.syncNow() | |
| 162 | + | |
| 163 | + val unchanged = db.noteProjetDao().getByServerId("n1") | |
| 164 | + assertEquals(titreOriginal, unchanged?.titre) | |
| 165 | + } | |
| 166 | + | |
| 167 | + // ---- Tâche 4 : tombstone note → suppression ---- | |
| 168 | + | |
| 169 | + @Test | |
| 170 | + fun pull_tombstoneNote_suppression() = runBlocking { | |
| 171 | + db.noteProjetDao().upsert( | |
| 172 | + NoteProjetEntity( | |
| 173 | + serverId = "n-delete", | |
| 174 | + projetServerId = "p1", | |
| 175 | + titre = "À supprimer", | |
| 176 | + texte = "Sera supprimée.", | |
| 177 | + auteur = "alice", | |
| 178 | + createdAt = parseIsoToEpochMs("2026-09-10T08:00:00Z"), | |
| 179 | + ), | |
| 180 | + ) | |
| 181 | + api.pullResult = AilianceApiClient.ApiResult.Ok( | |
| 182 | + SyncPullResponse( | |
| 183 | + serverTime = "2026-09-15T10:00:00Z", | |
| 184 | + tombstones = listOf( | |
| 185 | + TombstoneDto( | |
| 186 | + entityType = "note", | |
| 187 | + id = "n-delete", | |
| 188 | + supprimeLe = "2026-09-15T09:00:00Z", | |
| 189 | + ), | |
| 190 | + ), | |
| 191 | + ), | |
| 192 | + ) | |
| 193 | + | |
| 194 | + engine.syncNow() | |
| 195 | + | |
| 196 | + assertNull(db.noteProjetDao().getByServerId("n-delete")) | |
| 197 | + } | |
| 198 | + | |
| 199 | + // ---- Tâche 5 : pull sans section notes (ancien serveur) → aucune erreur ---- | |
| 200 | + | |
| 201 | + @Test | |
| 202 | + fun pull_sansSectionNotes_aucuneErreur() = runBlocking { | |
| 203 | + // SyncPullResponse sans `notes` explicite → default emptyList() (compat anciens serveurs) | |
| 204 | + api.pullResult = AilianceApiClient.ApiResult.Ok( | |
| 205 | + SyncPullResponse(serverTime = "2026-09-15T10:00:00Z"), | |
| 206 | + ) | |
| 207 | + | |
| 208 | + val result = engine.syncNow() | |
| 209 | + | |
| 210 | + assertEquals(true, result.success) | |
| 211 | + assertEquals(0, db.noteProjetDao().listAll().size) | |
| 212 | + } | |
| 213 | +} |
A
android/app/src/test/java/fr/ebii/card2vcf/sync/NoteVocaleSuppressionRelanceTest.kt
+269
-0
@@ -0,0 +1,269 @@
| 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.InteractionEntity | |
| 8 | +import fr.ebii.card2vcf.data.NoteProjetEntity | |
| 9 | +import kotlinx.coroutines.runBlocking | |
| 10 | +import org.junit.After | |
| 11 | +import org.junit.Assert.assertEquals | |
| 12 | +import org.junit.Assert.assertNotNull | |
| 13 | +import org.junit.Assert.assertNull | |
| 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 | + | |
| 20 | +@RunWith(RobolectricTestRunner::class) | |
| 21 | +@Config(sdk = [31]) | |
| 22 | +class NoteVocaleSuppressionRelanceTest { | |
| 23 | + | |
| 24 | + private lateinit var db: CrmDatabase | |
| 25 | + private lateinit var api: FakeAilianceApi | |
| 26 | + private lateinit var engine: SyncEngine | |
| 27 | + | |
| 28 | + @Before | |
| 29 | + fun setUp() { | |
| 30 | + val ctx = ApplicationProvider.getApplicationContext<Context>() | |
| 31 | + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java) | |
| 32 | + .allowMainThreadQueries() | |
| 33 | + .build() | |
| 34 | + api = FakeAilianceApi() | |
| 35 | + engine = SyncEngine(api, db) | |
| 36 | + } | |
| 37 | + | |
| 38 | + @After | |
| 39 | + fun tearDown() = db.close() | |
| 40 | + | |
| 41 | + // ---- Push interaction delete ---- | |
| 42 | + | |
| 43 | + @Test | |
| 44 | + fun push_interactionDelete_appelApiEtOpConsommee() = runBlocking { | |
| 45 | + db.interactionDao().upsert( | |
| 46 | + InteractionEntity( | |
| 47 | + serverId = "iid-srv-1", | |
| 48 | + contactServerId = "contact-srv-1", | |
| 49 | + type = "note_vocale", | |
| 50 | + sujet = "Note à supprimer", | |
| 51 | + createdAt = 1L, | |
| 52 | + ), | |
| 53 | + ) | |
| 54 | + db.syncOpDao().insert( | |
| 55 | + SyncOpEntity( | |
| 56 | + entityType = "interaction", | |
| 57 | + op = "delete", | |
| 58 | + payloadJson = "{}", | |
| 59 | + serverId = "iid-srv-1", | |
| 60 | + createdAt = 1L, | |
| 61 | + ), | |
| 62 | + ) | |
| 63 | + | |
| 64 | + engine.syncNow() | |
| 65 | + | |
| 66 | + assertEquals(1, api.deleteInteractionCalls.size) | |
| 67 | + assertEquals("iid-srv-1", api.deleteInteractionCalls[0]) | |
| 68 | + assertEquals(0, db.syncOpDao().listAll().size) | |
| 69 | + } | |
| 70 | + | |
| 71 | + @Test | |
| 72 | + fun push_interactionDelete_sansSrvId_opConservee() = runBlocking { | |
| 73 | + db.syncOpDao().insert( | |
| 74 | + SyncOpEntity( | |
| 75 | + entityType = "interaction", | |
| 76 | + op = "delete", | |
| 77 | + payloadJson = "{}", | |
| 78 | + serverId = null, | |
| 79 | + createdAt = 1L, | |
| 80 | + ), | |
| 81 | + ) | |
| 82 | + | |
| 83 | + engine.syncNow() | |
| 84 | + | |
| 85 | + assertEquals(0, api.deleteInteractionCalls.size) | |
| 86 | + // op sans serverId : abandon sans consommation | |
| 87 | + assertEquals(1, db.syncOpDao().listAll().size) | |
| 88 | + } | |
| 89 | + | |
| 90 | + @Test | |
| 91 | + fun push_interactionDelete_echecApi_opConservee() = runBlocking { | |
| 92 | + api.deleteInteractionResult = AilianceApiClient.ApiResult.Err(503, "Service indisponible") | |
| 93 | + db.syncOpDao().insert( | |
| 94 | + SyncOpEntity( | |
| 95 | + entityType = "interaction", | |
| 96 | + op = "delete", | |
| 97 | + payloadJson = "{}", | |
| 98 | + serverId = "iid-srv-2", | |
| 99 | + createdAt = 1L, | |
| 100 | + ), | |
| 101 | + ) | |
| 102 | + | |
| 103 | + engine.syncNow() | |
| 104 | + | |
| 105 | + assertEquals(1, api.deleteInteractionCalls.size) | |
| 106 | + assertEquals(1, db.syncOpDao().listAll().size) | |
| 107 | + } | |
| 108 | + | |
| 109 | + // ---- Pull interaction avec transcriptionErreur ---- | |
| 110 | + | |
| 111 | + @Test | |
| 112 | + fun pull_interaction_avecTranscriptionErreur_stockeChamp() = runBlocking { | |
| 113 | + api.pullResult = AilianceApiClient.ApiResult.Ok( | |
| 114 | + SyncPullResponse( | |
| 115 | + serverTime = "2026-09-15T10:00:00Z", | |
| 116 | + interactions = listOf( | |
| 117 | + InteractionDto( | |
| 118 | + id = "int-err-1", | |
| 119 | + contactId = "contact-srv-1", | |
| 120 | + typeInteraction = "note_vocale", | |
| 121 | + sujet = "Note en échec", | |
| 122 | + creeLe = "2026-09-14T08:00:00Z", | |
| 123 | + transcription = "echec", | |
| 124 | + transcriptionErreur = "délai de 30 s dépassé", | |
| 125 | + ), | |
| 126 | + ), | |
| 127 | + ), | |
| 128 | + ) | |
| 129 | + | |
| 130 | + engine.syncNow() | |
| 131 | + | |
| 132 | + val stored = db.interactionDao().getByServerId("int-err-1") | |
| 133 | + assertNotNull(stored) | |
| 134 | + assertEquals("echec", stored?.transcriptionStatut) | |
| 135 | + assertEquals("délai de 30 s dépassé", stored?.transcriptionErreur) | |
| 136 | + } | |
| 137 | + | |
| 138 | + @Test | |
| 139 | + fun pull_interaction_transcriptionErreur_misAJourSurExistant() = runBlocking { | |
| 140 | + db.interactionDao().upsert( | |
| 141 | + InteractionEntity( | |
| 142 | + serverId = "int-err-2", | |
| 143 | + contactServerId = "contact-srv-1", | |
| 144 | + type = "note_vocale", | |
| 145 | + sujet = "Note", | |
| 146 | + transcriptionStatut = "en_attente", | |
| 147 | + transcriptionErreur = null, | |
| 148 | + createdAt = parseIsoToEpochMs("2026-09-14T08:00:00Z"), | |
| 149 | + updatedAt = parseIsoToEpochMs("2026-09-14T08:00:00Z"), | |
| 150 | + ), | |
| 151 | + ) | |
| 152 | + | |
| 153 | + api.pullResult = AilianceApiClient.ApiResult.Ok( | |
| 154 | + SyncPullResponse( | |
| 155 | + serverTime = "2026-09-15T10:00:00Z", | |
| 156 | + interactions = listOf( | |
| 157 | + InteractionDto( | |
| 158 | + id = "int-err-2", | |
| 159 | + contactId = "contact-srv-1", | |
| 160 | + typeInteraction = "note_vocale", | |
| 161 | + sujet = "Note", | |
| 162 | + creeLe = "2026-09-14T08:00:00Z", | |
| 163 | + misAJourLe = "2026-09-15T09:00:00Z", | |
| 164 | + transcription = "echec", | |
| 165 | + transcriptionErreur = "modèle introuvable", | |
| 166 | + ), | |
| 167 | + ), | |
| 168 | + ), | |
| 169 | + ) | |
| 170 | + | |
| 171 | + engine.syncNow() | |
| 172 | + | |
| 173 | + val updated = db.interactionDao().getByServerId("int-err-2") | |
| 174 | + assertEquals("echec", updated?.transcriptionStatut) | |
| 175 | + assertEquals("modèle introuvable", updated?.transcriptionErreur) | |
| 176 | + } | |
| 177 | + | |
| 178 | + // ---- Pull note_projet avec transcriptionErreur ---- | |
| 179 | + | |
| 180 | + @Test | |
| 181 | + fun pull_noteProjet_avecTranscriptionErreur_stockeChamp() = runBlocking { | |
| 182 | + api.pullResult = AilianceApiClient.ApiResult.Ok( | |
| 183 | + SyncPullResponse( | |
| 184 | + serverTime = "2026-09-15T10:00:00Z", | |
| 185 | + notes = listOf( | |
| 186 | + NoteProjetDto( | |
| 187 | + id = "note-err-1", | |
| 188 | + projetId = "projet-srv-1", | |
| 189 | + titre = "Note en échec", | |
| 190 | + contenu = "", | |
| 191 | + auteur = "alice", | |
| 192 | + creeLe = "2026-09-14T08:00:00Z", | |
| 193 | + transcription = "echec", | |
| 194 | + transcriptionErreur = "audio corrompu", | |
| 195 | + ), | |
| 196 | + ), | |
| 197 | + ), | |
| 198 | + ) | |
| 199 | + | |
| 200 | + engine.syncNow() | |
| 201 | + | |
| 202 | + val stored = db.noteProjetDao().getByServerId("note-err-1") | |
| 203 | + assertNotNull(stored) | |
| 204 | + assertEquals("echec", stored?.transcriptionStatut) | |
| 205 | + assertEquals("audio corrompu", stored?.transcriptionErreur) | |
| 206 | + } | |
| 207 | + | |
| 208 | + @Test | |
| 209 | + fun pull_noteProjet_transcriptionTerminee_erreurEffacee() = runBlocking { | |
| 210 | + db.noteProjetDao().upsert( | |
| 211 | + NoteProjetEntity( | |
| 212 | + serverId = "note-err-2", | |
| 213 | + projetServerId = "projet-srv-1", | |
| 214 | + titre = "Note", | |
| 215 | + texte = "", | |
| 216 | + auteur = "alice", | |
| 217 | + transcriptionStatut = "echec", | |
| 218 | + transcriptionErreur = "ancienne erreur", | |
| 219 | + createdAt = parseIsoToEpochMs("2026-09-14T08:00:00Z"), | |
| 220 | + updatedAt = parseIsoToEpochMs("2026-09-14T08:00:00Z"), | |
| 221 | + ), | |
| 222 | + ) | |
| 223 | + | |
| 224 | + api.pullResult = AilianceApiClient.ApiResult.Ok( | |
| 225 | + SyncPullResponse( | |
| 226 | + serverTime = "2026-09-15T10:00:00Z", | |
| 227 | + notes = listOf( | |
| 228 | + NoteProjetDto( | |
| 229 | + id = "note-err-2", | |
| 230 | + projetId = "projet-srv-1", | |
| 231 | + titre = "Note", | |
| 232 | + contenu = "Transcription OK.", | |
| 233 | + auteur = "alice", | |
| 234 | + creeLe = "2026-09-14T08:00:00Z", | |
| 235 | + majLe = "2026-09-15T09:00:00Z", | |
| 236 | + transcription = "terminee", | |
| 237 | + transcriptionErreur = null, | |
| 238 | + ), | |
| 239 | + ), | |
| 240 | + ), | |
| 241 | + ) | |
| 242 | + | |
| 243 | + engine.syncNow() | |
| 244 | + | |
| 245 | + val updated = db.noteProjetDao().getByServerId("note-err-2") | |
| 246 | + assertEquals("terminee", updated?.transcriptionStatut) | |
| 247 | + assertNull(updated?.transcriptionErreur) | |
| 248 | + } | |
| 249 | + | |
| 250 | + // ---- FakeAilianceApi relance (smoke tests) ---- | |
| 251 | + | |
| 252 | + @Test | |
| 253 | + fun fake_relancerTranscriptionInteraction_enregistreAppel() { | |
| 254 | + val result = api.relancerTranscriptionInteraction("contact-srv-1", "iid-srv-1") | |
| 255 | + | |
| 256 | + assertEquals(AilianceApiClient.ApiResult.Ok(Unit), result) | |
| 257 | + assertEquals(1, api.relancerTranscriptionInteractionCalls.size) | |
| 258 | + assertEquals("contact-srv-1" to "iid-srv-1", api.relancerTranscriptionInteractionCalls[0]) | |
| 259 | + } | |
| 260 | + | |
| 261 | + @Test | |
| 262 | + fun fake_relancerTranscriptionNote_enregistreAppel() { | |
| 263 | + val result = api.relancerTranscriptionNote("projet-srv-1", "note-srv-1") | |
| 264 | + | |
| 265 | + assertEquals(AilianceApiClient.ApiResult.Ok(Unit), result) | |
| 266 | + assertEquals(1, api.relancerTranscriptionNoteCalls.size) | |
| 267 | + assertEquals("projet-srv-1" to "note-srv-1", api.relancerTranscriptionNoteCalls[0]) | |
| 268 | + } | |
| 269 | +} |
A
android/app/src/test/java/fr/ebii/card2vcf/ui/audio/FormaterDureeTest.kt
+45
-0
@@ -0,0 +1,45 @@
| 1 | +package fr.ebii.card2vcf.ui.audio | |
| 2 | + | |
| 3 | +import org.junit.Assert.assertEquals | |
| 4 | +import org.junit.Test | |
| 5 | + | |
| 6 | +class FormaterDureeTest { | |
| 7 | + | |
| 8 | + @Test | |
| 9 | + fun zero_ms_retourne_00_00() { | |
| 10 | + assertEquals("00:00", formaterDuree(0L)) | |
| 11 | + } | |
| 12 | + | |
| 13 | + @Test | |
| 14 | + fun moins_dune_minute() { | |
| 15 | + assertEquals("00:07", formaterDuree(7_000L)) | |
| 16 | + assertEquals("00:59", formaterDuree(59_000L)) | |
| 17 | + } | |
| 18 | + | |
| 19 | + @Test | |
| 20 | + fun exactement_une_minute() { | |
| 21 | + assertEquals("01:00", formaterDuree(60_000L)) | |
| 22 | + } | |
| 23 | + | |
| 24 | + @Test | |
| 25 | + fun une_minute_trente() { | |
| 26 | + assertEquals("01:30", formaterDuree(90_000L)) | |
| 27 | + } | |
| 28 | + | |
| 29 | + @Test | |
| 30 | + fun arrondi_vers_le_bas_les_ms_residuelles() { | |
| 31 | + // 61 999 ms → 1 min 1 sec | |
| 32 | + assertEquals("01:01", formaterDuree(61_999L)) | |
| 33 | + } | |
| 34 | + | |
| 35 | + @Test | |
| 36 | + fun dix_minutes() { | |
| 37 | + assertEquals("10:00", formaterDuree(600_000L)) | |
| 38 | + } | |
| 39 | + | |
| 40 | + @Test | |
| 41 | + fun plus_dune_heure_affiche_minutes_totales() { | |
| 42 | + // 65 min 3 sec | |
| 43 | + assertEquals("65:03", formaterDuree(3_903_000L)) | |
| 44 | + } | |
| 45 | +} |
A
android/app/src/test/java/fr/ebii/card2vcf/ui/contact/ContactPagerViewModelTest.kt
+163
-0
@@ -0,0 +1,163 @@
| 1 | +package fr.ebii.card2vcf.ui.contact | |
| 2 | + | |
| 3 | +import android.content.Context | |
| 4 | +import androidx.room.Room | |
| 5 | +import androidx.test.core.app.ApplicationProvider | |
| 6 | +import fr.ebii.card2vcf.crm.ContactSort | |
| 7 | +import fr.ebii.card2vcf.data.AudioNoteStore | |
| 8 | +import fr.ebii.card2vcf.data.ContactImageStore | |
| 9 | +import fr.ebii.card2vcf.data.ContactRepository | |
| 10 | +import fr.ebii.card2vcf.data.CrmDatabase | |
| 11 | +import fr.ebii.card2vcf.sync.AilianceApiClient | |
| 12 | +import fr.ebii.card2vcf.sync.FakeAilianceApi | |
| 13 | +import fr.ebii.card2vcf.sync.SyncCredentialsStore | |
| 14 | +import fr.ebii.card2vcf.sync.SyncEngine | |
| 15 | +import fr.ebii.card2vcf.ui.audio.ConfirmationNote | |
| 16 | +import kotlinx.coroutines.Dispatchers | |
| 17 | +import kotlinx.coroutines.ExperimentalCoroutinesApi | |
| 18 | +import kotlinx.coroutines.flow.first | |
| 19 | +import kotlinx.coroutines.runBlocking | |
| 20 | +import kotlinx.coroutines.test.resetMain | |
| 21 | +import kotlinx.coroutines.test.setMain | |
| 22 | +import org.junit.After | |
| 23 | +import org.junit.Assert.assertEquals | |
| 24 | +import org.junit.Assert.assertNull | |
| 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 | +@OptIn(ExperimentalCoroutinesApi::class) | |
| 32 | +@RunWith(RobolectricTestRunner::class) | |
| 33 | +@Config(sdk = [31]) | |
| 34 | +class ContactPagerViewModelTest { | |
| 35 | + | |
| 36 | + private lateinit var db: CrmDatabase | |
| 37 | + private lateinit var repository: ContactRepository | |
| 38 | + private lateinit var credentialsStore: SyncCredentialsStore | |
| 39 | + private lateinit var audioStore: AudioNoteStore | |
| 40 | + private val contactServerId = "contact-srv-1" | |
| 41 | + | |
| 42 | + @Before | |
| 43 | + fun setUp() { | |
| 44 | + Dispatchers.setMain(Dispatchers.Unconfined) | |
| 45 | + val ctx = ApplicationProvider.getApplicationContext<Context>() | |
| 46 | + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java) | |
| 47 | + .allowMainThreadQueries() | |
| 48 | + .setQueryExecutor { it.run() } | |
| 49 | + .setTransactionExecutor { it.run() } | |
| 50 | + .build() | |
| 51 | + repository = ContactRepository( | |
| 52 | + dao = db.crmContactDao(), | |
| 53 | + images = ContactImageStore(ctx), | |
| 54 | + syncOpDao = db.syncOpDao(), | |
| 55 | + ) | |
| 56 | + credentialsStore = SyncCredentialsStore(ctx.getSharedPreferences("test_creds_pager", Context.MODE_PRIVATE)) | |
| 57 | + audioStore = AudioNoteStore(ctx) | |
| 58 | + } | |
| 59 | + | |
| 60 | + @After | |
| 61 | + fun tearDown() { | |
| 62 | + db.close() | |
| 63 | + Dispatchers.resetMain() | |
| 64 | + } | |
| 65 | + | |
| 66 | + private fun viewModel(engineFabrique: () -> SyncEngine? = { null }) = ContactPagerViewModel( | |
| 67 | + repository = repository, | |
| 68 | + database = db, | |
| 69 | + credentialsStore = credentialsStore, | |
| 70 | + audioStore = audioStore, | |
| 71 | + initialContactId = 0L, | |
| 72 | + sort = ContactSort.LAST_NAME, | |
| 73 | + engineFabrique = engineFabrique, | |
| 74 | + ) | |
| 75 | + | |
| 76 | + @Test | |
| 77 | + fun ajouterNoteVocale_insereInteractionDansDB() = runBlocking { | |
| 78 | + val vm = viewModel() | |
| 79 | + vm.ajouterNoteVocale(contactServerId, "Appel client", "Résumé de l'appel", audioPath = null, modeServeur = false) | |
| 80 | + | |
| 81 | + val interactions = db.interactionDao() | |
| 82 | + .observeByContactServerId(contactServerId) | |
| 83 | + .first { it.isNotEmpty() } | |
| 84 | + assertEquals(1, interactions.size) | |
| 85 | + val note = interactions[0] | |
| 86 | + assertEquals("note_vocale", note.type) | |
| 87 | + assertEquals("Appel client", note.sujet) | |
| 88 | + assertEquals("Résumé de l'appel", note.description) | |
| 89 | + assertEquals(contactServerId, note.contactServerId) | |
| 90 | + assertNull("Pas de statut sans audio en mode local", note.transcriptionStatut) | |
| 91 | + } | |
| 92 | + | |
| 93 | + @Test | |
| 94 | + fun ajouterNoteVocale_modeServeurAvecAudio_statut_en_attente() = runBlocking { | |
| 95 | + val vm = viewModel() | |
| 96 | + vm.ajouterNoteVocale(contactServerId, "Note audio", "", audioPath = "/tmp/note.wav", modeServeur = true) | |
| 97 | + | |
| 98 | + val interactions = db.interactionDao() | |
| 99 | + .observeByContactServerId(contactServerId) | |
| 100 | + .first { it.isNotEmpty() } | |
| 101 | + assertEquals("en_attente", interactions[0].transcriptionStatut) | |
| 102 | + } | |
| 103 | + | |
| 104 | + @Test | |
| 105 | + fun ajouterNoteVocale_insereOpDeSyncCreate() = runBlocking { | |
| 106 | + viewModel().ajouterNoteVocale(contactServerId, "Sujet", "Corps", audioPath = null, modeServeur = false) | |
| 107 | + | |
| 108 | + val ops = db.syncOpDao().listAll() | |
| 109 | + assertEquals(1, ops.size) | |
| 110 | + val op = ops[0] | |
| 111 | + assertEquals("interaction", op.entityType) | |
| 112 | + assertEquals("create", op.op) | |
| 113 | + assertEquals(contactServerId, op.serverId) | |
| 114 | + } | |
| 115 | + | |
| 116 | + @Test | |
| 117 | + fun ajouterNoteVocale_sujetVide_nInsereRien() = runBlocking { | |
| 118 | + viewModel().ajouterNoteVocale(contactServerId, " ", "Corps", audioPath = null, modeServeur = false) | |
| 119 | + | |
| 120 | + val ops = db.syncOpDao().listAll() | |
| 121 | + assertEquals(0, ops.size) | |
| 122 | + } | |
| 123 | + | |
| 124 | + @Test | |
| 125 | + fun ajouterNoteVocale_modeServeurEnLigne_pushImmediatEtOpConsommee() = runBlocking { | |
| 126 | + val fakeApi = FakeAilianceApi() | |
| 127 | + val vm = viewModel { SyncEngine(fakeApi, db) } | |
| 128 | + vm.ajouterNoteVocale(contactServerId, "Note audio", "", audioPath = null, modeServeur = true) | |
| 129 | + | |
| 130 | + vm.confirmationNote.first { it != null } | |
| 131 | + | |
| 132 | + assertEquals(1, fakeApi.createInteractionCalls.size) | |
| 133 | + assertEquals(0, db.syncOpDao().listAll().size) | |
| 134 | + assertEquals(ConfirmationNote.SERVEUR_OK, vm.confirmationNote.value) | |
| 135 | + } | |
| 136 | + | |
| 137 | + @Test | |
| 138 | + fun ajouterNoteVocale_modeServeurEchecReseau_opConserveeEtMessageRepli() = runBlocking { | |
| 139 | + val fakeApi = FakeAilianceApi().also { | |
| 140 | + it.createInteractionResult = AilianceApiClient.ApiResult.Err(503, "KO") | |
| 141 | + } | |
| 142 | + val vm = viewModel { SyncEngine(fakeApi, db) } | |
| 143 | + vm.ajouterNoteVocale(contactServerId, "Note réseau KO", "", audioPath = null, modeServeur = true) | |
| 144 | + | |
| 145 | + vm.confirmationNote.first { it != null } | |
| 146 | + | |
| 147 | + assertEquals(1, db.syncOpDao().listAll().size) | |
| 148 | + assertEquals(ConfirmationNote.SERVEUR_REPLI, vm.confirmationNote.value) | |
| 149 | + } | |
| 150 | + | |
| 151 | + @Test | |
| 152 | + fun ajouterNoteVocale_modeAppareil_aucunPush() = runBlocking { | |
| 153 | + val fakeApi = FakeAilianceApi() | |
| 154 | + val vm = viewModel { SyncEngine(fakeApi, db) } | |
| 155 | + vm.ajouterNoteVocale(contactServerId, "Note locale", "", audioPath = null, modeServeur = false) | |
| 156 | + | |
| 157 | + vm.confirmationNote.first { it != null } | |
| 158 | + | |
| 159 | + assertEquals(0, fakeApi.createInteractionCalls.size) | |
| 160 | + assertEquals(1, db.syncOpDao().listAll().size) | |
| 161 | + assertEquals(ConfirmationNote.APPAREIL, vm.confirmationNote.value) | |
| 162 | + } | |
| 163 | +} |
A
android/app/src/test/java/fr/ebii/card2vcf/ui/projets/ProjetDetailViewModelTest.kt
+183
-0
@@ -0,0 +1,183 @@
| 1 | +package fr.ebii.card2vcf.ui.projets | |
| 2 | + | |
| 3 | +import android.content.Context | |
| 4 | +import androidx.room.Room | |
| 5 | +import androidx.test.core.app.ApplicationProvider | |
| 6 | +import fr.ebii.card2vcf.data.AudioNoteStore | |
| 7 | +import fr.ebii.card2vcf.data.CrmDatabase | |
| 8 | +import fr.ebii.card2vcf.data.NoteProjetEntity | |
| 9 | +import fr.ebii.card2vcf.sync.AilianceApiClient | |
| 10 | +import fr.ebii.card2vcf.sync.FakeAilianceApi | |
| 11 | +import fr.ebii.card2vcf.sync.SyncCredentialsStore | |
| 12 | +import fr.ebii.card2vcf.sync.SyncEngine | |
| 13 | +import fr.ebii.card2vcf.ui.audio.ConfirmationNote | |
| 14 | +import kotlinx.coroutines.Dispatchers | |
| 15 | +import kotlinx.coroutines.ExperimentalCoroutinesApi | |
| 16 | +import kotlinx.coroutines.flow.first | |
| 17 | +import kotlinx.coroutines.runBlocking | |
| 18 | +import kotlinx.coroutines.test.resetMain | |
| 19 | +import kotlinx.coroutines.test.setMain | |
| 20 | +import org.junit.After | |
| 21 | +import org.junit.Assert.assertEquals | |
| 22 | +import org.junit.Assert.assertNull | |
| 23 | +import org.junit.Before | |
| 24 | +import org.junit.Test | |
| 25 | +import org.junit.runner.RunWith | |
| 26 | +import org.robolectric.RobolectricTestRunner | |
| 27 | +import org.robolectric.annotation.Config | |
| 28 | + | |
| 29 | +@OptIn(ExperimentalCoroutinesApi::class) | |
| 30 | +@RunWith(RobolectricTestRunner::class) | |
| 31 | +@Config(sdk = [31]) | |
| 32 | +class ProjetDetailViewModelTest { | |
| 33 | + | |
| 34 | + private lateinit var db: CrmDatabase | |
| 35 | + private lateinit var credentialsStore: SyncCredentialsStore | |
| 36 | + private lateinit var audioStore: AudioNoteStore | |
| 37 | + private val projetServerId = "proj-1" | |
| 38 | + | |
| 39 | + @Before | |
| 40 | + fun setUp() { | |
| 41 | + Dispatchers.setMain(Dispatchers.Unconfined) | |
| 42 | + val ctx = ApplicationProvider.getApplicationContext<Context>() | |
| 43 | + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java) | |
| 44 | + .allowMainThreadQueries() | |
| 45 | + .setQueryExecutor { it.run() } | |
| 46 | + .setTransactionExecutor { it.run() } | |
| 47 | + .build() | |
| 48 | + // Utilise le constructeur interne pour éviter EncryptedSharedPreferences en test. | |
| 49 | + credentialsStore = SyncCredentialsStore(ctx.getSharedPreferences("test_creds", Context.MODE_PRIVATE)) | |
| 50 | + audioStore = AudioNoteStore(ctx) | |
| 51 | + } | |
| 52 | + | |
| 53 | + @After | |
| 54 | + fun tearDown() { | |
| 55 | + db.close() | |
| 56 | + Dispatchers.resetMain() | |
| 57 | + } | |
| 58 | + | |
| 59 | + private fun viewModel(engineFabrique: () -> SyncEngine? = { null }) = | |
| 60 | + ProjetDetailViewModel(db, credentialsStore, audioStore, projetServerId, engineFabrique) | |
| 61 | + | |
| 62 | + @Test | |
| 63 | + fun notesExposeesPourLeProjetUniquement() = runBlocking { | |
| 64 | + db.noteProjetDao().upsert( | |
| 65 | + NoteProjetEntity(projetServerId = projetServerId, titre = "Ma note", texte = "contenu", auteur = "alice", createdAt = 1000L), | |
| 66 | + ) | |
| 67 | + db.noteProjetDao().upsert( | |
| 68 | + NoteProjetEntity(projetServerId = "autre-projet", titre = "Autre", texte = "x", auteur = "bob", createdAt = 2000L), | |
| 69 | + ) | |
| 70 | + | |
| 71 | + val notes = viewModel().notes.first { it.isNotEmpty() } | |
| 72 | + | |
| 73 | + assertEquals(1, notes.size) | |
| 74 | + assertEquals("Ma note", notes[0].titre) | |
| 75 | + } | |
| 76 | + | |
| 77 | + @Test | |
| 78 | + fun notesTrieesParUpdatedAtOuCreatedAtDecroissant() = runBlocking { | |
| 79 | + val base = 1_000_000L | |
| 80 | + // updatedAt = base+300 → clé de tri la plus haute | |
| 81 | + db.noteProjetDao().upsert( | |
| 82 | + NoteProjetEntity(projetServerId = projetServerId, titre = "AvecUpdate", texte = "", auteur = "a", createdAt = base, updatedAt = base + 300), | |
| 83 | + ) | |
| 84 | + // pas d'updatedAt → clé = createdAt = base+200 | |
| 85 | + db.noteProjetDao().upsert( | |
| 86 | + NoteProjetEntity(projetServerId = projetServerId, titre = "SansUpdate", texte = "", auteur = "b", createdAt = base + 200), | |
| 87 | + ) | |
| 88 | + // updatedAt = base+100 → clé la plus basse | |
| 89 | + db.noteProjetDao().upsert( | |
| 90 | + NoteProjetEntity(projetServerId = projetServerId, titre = "UpdateAncien", texte = "", auteur = "c", createdAt = base + 500, updatedAt = base + 100), | |
| 91 | + ) | |
| 92 | + | |
| 93 | + val notes = viewModel().notes.first { it.size == 3 } | |
| 94 | + | |
| 95 | + // tri attendu DESC : base+300, base+200, base+100 | |
| 96 | + assertEquals("AvecUpdate", notes[0].titre) | |
| 97 | + assertEquals("SansUpdate", notes[1].titre) | |
| 98 | + assertEquals("UpdateAncien", notes[2].titre) | |
| 99 | + } | |
| 100 | + | |
| 101 | + @Test | |
| 102 | + fun ajouterNoteVocale_insereEntityDansDB() = runBlocking { | |
| 103 | + val vm = viewModel() | |
| 104 | + vm.ajouterNoteVocale("Réunion client", "Résumé de la réunion", audioPath = null, modeServeur = false) | |
| 105 | + | |
| 106 | + val notes = vm.notes.first { it.isNotEmpty() } | |
| 107 | + assertEquals(1, notes.size) | |
| 108 | + assertEquals("Réunion client", notes[0].titre) | |
| 109 | + assertEquals("Résumé de la réunion", notes[0].texte) | |
| 110 | + assertEquals(projetServerId, notes[0].projetServerId) | |
| 111 | + assertNull("Pas de transcription en mode local sans audio", notes[0].transcriptionStatut) | |
| 112 | + } | |
| 113 | + | |
| 114 | + @Test | |
| 115 | + fun ajouterNoteVocale_modeServeurAvecAudio_statut_en_attente() = runBlocking { | |
| 116 | + val vm = viewModel() | |
| 117 | + vm.ajouterNoteVocale("Note audio", "", audioPath = "/tmp/note.wav", modeServeur = true) | |
| 118 | + | |
| 119 | + val notes = vm.notes.first { it.isNotEmpty() } | |
| 120 | + assertEquals("en_attente", notes[0].transcriptionStatut) | |
| 121 | + } | |
| 122 | + | |
| 123 | + @Test | |
| 124 | + fun ajouterNoteVocale_modeServeurSansAudio_statut_null() = runBlocking { | |
| 125 | + val vm = viewModel() | |
| 126 | + vm.ajouterNoteVocale("Note sans audio", "", audioPath = null, modeServeur = true) | |
| 127 | + | |
| 128 | + val notes = vm.notes.first { it.isNotEmpty() } | |
| 129 | + assertNull(notes[0].transcriptionStatut) | |
| 130 | + } | |
| 131 | + | |
| 132 | + @Test | |
| 133 | + fun ajouterNoteVocale_insereOpDeSyncCreate() = runBlocking { | |
| 134 | + viewModel().ajouterNoteVocale("Sujet", "Corps", audioPath = "/tmp/a.wav", modeServeur = true) | |
| 135 | + | |
| 136 | + val ops = db.syncOpDao().listAll() | |
| 137 | + assertEquals(1, ops.size) | |
| 138 | + val op = ops[0] | |
| 139 | + assertEquals("note_projet", op.entityType) | |
| 140 | + assertEquals("create", op.op) | |
| 141 | + assertEquals(projetServerId, op.serverId) | |
| 142 | + } | |
| 143 | + | |
| 144 | + @Test | |
| 145 | + fun ajouterNoteVocale_modeServeurEnLigne_pushImmediatEtOpConsommee() = runBlocking { | |
| 146 | + val fakeApi = FakeAilianceApi() | |
| 147 | + val vm = viewModel { SyncEngine(fakeApi, db) } | |
| 148 | + vm.ajouterNoteVocale("Note audio", "", audioPath = null, modeServeur = true) | |
| 149 | + | |
| 150 | + vm.confirmationNote.first { it != null } | |
| 151 | + | |
| 152 | + assertEquals(1, fakeApi.createNoteProjetCalls.size) | |
| 153 | + assertEquals(0, db.syncOpDao().listAll().size) | |
| 154 | + assertEquals(ConfirmationNote.SERVEUR_OK, vm.confirmationNote.value) | |
| 155 | + } | |
| 156 | + | |
| 157 | + @Test | |
| 158 | + fun ajouterNoteVocale_modeServeurEchecReseau_opConserveeEtMessageRepli() = runBlocking { | |
| 159 | + val fakeApi = FakeAilianceApi().also { | |
| 160 | + it.createNoteProjetResult = AilianceApiClient.ApiResult.Err(503, "KO") | |
| 161 | + } | |
| 162 | + val vm = viewModel { SyncEngine(fakeApi, db) } | |
| 163 | + vm.ajouterNoteVocale("Note réseau KO", "", audioPath = null, modeServeur = true) | |
| 164 | + | |
| 165 | + vm.confirmationNote.first { it != null } | |
| 166 | + | |
| 167 | + assertEquals(1, db.syncOpDao().listAll().size) | |
| 168 | + assertEquals(ConfirmationNote.SERVEUR_REPLI, vm.confirmationNote.value) | |
| 169 | + } | |
| 170 | + | |
| 171 | + @Test | |
| 172 | + fun ajouterNoteVocale_modeAppareil_aucunPush() = runBlocking { | |
| 173 | + val fakeApi = FakeAilianceApi() | |
| 174 | + val vm = viewModel { SyncEngine(fakeApi, db) } | |
| 175 | + vm.ajouterNoteVocale("Note locale", "", audioPath = null, modeServeur = false) | |
| 176 | + | |
| 177 | + vm.confirmationNote.first { it != null } | |
| 178 | + | |
| 179 | + assertEquals(0, fakeApi.createNoteProjetCalls.size) | |
| 180 | + assertEquals(1, db.syncOpDao().listAll().size) | |
| 181 | + assertEquals(ConfirmationNote.APPAREIL, vm.confirmationNote.value) | |
| 182 | + } | |
| 183 | +} |
M
android/app/src/test/java/fr/ebii/card2vcf/ui/settings/SettingsViewModelTest.kt
+10
-0
@@ -53,6 +53,9 @@ private class FakeAilianceApi(
| 53 | 53 | override fun deleteTache(projetId: String, tacheId: String) = error("not used") |
| 54 | 54 | override fun moveTache(projetId: String, tacheId: String, jsonBody: String) = error("not used") |
| 55 | 55 | override fun createInteraction(contactId: String, jsonBody: String) = error("not used") |
| 56 | + override fun deleteInteraction(iid: String) = error("not used") | |
| 57 | + override fun relancerTranscriptionInteraction(contactServerId: String, iid: String) = error("not used") | |
| 58 | + override fun relancerTranscriptionNote(projetServerId: String, nid: String) = error("not used") | |
| 56 | 59 | override fun listRdv() = error("not used") |
| 57 | 60 | override fun createRdv(jsonBody: String) = error("not used") |
| 58 | 61 | override fun getRdv(id: String) = error("not used") |
@@ -68,6 +71,13 @@ private class FakeAilianceApi(
| 68 | 71 | override fun uploadContactPhoto(id: String, bytes: ByteArray, filename: String, contentType: String) = error("not used") |
| 69 | 72 | override fun downloadContactCarte(id: String) = error("not used") |
| 70 | 73 | override fun downloadContactPhoto(id: String) = error("not used") |
| 74 | + override fun uploadInteractionAudio(contactId: String, iid: String, bytes: ByteArray, filename: String, contentType: String) = error("not used") | |
| 75 | + override fun downloadInteractionAudio(contactId: String, iid: String) = error("not used") | |
| 76 | + override fun createNoteProjet(projetId: String, jsonBody: String) = error("not used") | |
| 77 | + override fun updateNoteProjet(projetId: String, nid: String, jsonBody: String) = error("not used") | |
| 78 | + override fun deleteNoteProjet(projetId: String, nid: String) = error("not used") | |
| 79 | + override fun uploadNoteAudio(projetId: String, nid: String, bytes: ByteArray, filename: String, contentType: String) = error("not used") | |
| 80 | + override fun downloadNoteAudio(projetId: String, nid: String) = error("not used") | |
| 71 | 81 | } |
| 72 | 82 | |
| 73 | 83 | @OptIn(ExperimentalCoroutinesApi::class) |
M
android/settings.gradle.kts
+1
-0
@@ -11,6 +11,7 @@ dependencyResolutionManagement {
| 11 | 11 | google() |
| 12 | 12 | mavenCentral() |
| 13 | 13 | maven { url = uri("https://jitpack.io") } |
| 14 | + maven { url = uri("https://alphacephei.com/maven") } | |
| 14 | 15 | } |
| 15 | 16 | } |
| 16 | 17 | rootProject.name = "card2vcf" |
A
android/vosk-cache/vosk-model-small-fr-0.22.zip
+0
-0
GitRust