DepotModeles.kt
212 lignes · 8968 octets
package fr.ebii.card2vcf.modeles import android.content.Context import android.util.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import java.io.File import java.io.IOException import java.security.MessageDigest import java.util.zip.ZipInputStream /** * Téléchargement, vérification et suppression des modèles hors-ligne. * * Les traineddata atterrissent dans `filesDir/tesseract/tessdata/`, là où * `TesseractOcrEngine` les cherche déjà ; le modèle Vosk est décompressé à la racine * de `filesDir`, là où `TranscripteurVosk` l'attend. * * Un téléchargement passe par un fichier `.part` et n'est promu qu'après vérification * du SHA-256 épinglé au [CatalogueModeles] : un octet de travers et rien n'est installé. */ class DepotModeles( context: Context, private val prefs: PreferencesModeles, private val client: OkHttpClient = OkHttpClient(), ) { private val filesDir: File = context.applicationContext.filesDir private val _etats = MutableStateFlow<Map<String, EtatModele>>(emptyMap()) val etats: StateFlow<Map<String, EtatModele>> = _etats.asStateFlow() /** Relit le disque pour tout le catalogue — à appeler à l'ouverture de l'écran Modèles. */ fun rafraichir() { _etats.value = CatalogueModeles.tous.associate { it.id to etatDisque(it) } } /** État courant : ce qui est en mémoire (en cours, échec) prime sur le disque. */ fun etat(modele: ModeleLocal): EtatModele = _etats.value[modele.id] ?: etatDisque(modele) fun languesPretes(): List<String> { val selectionnees = prefs.languesSelectionnees() return CatalogueModeles.tessdata .filter { it.id in selectionnees && etatDisque(it) is EtatModele.Present } .map { it.id } } /** Ne lève jamais : un échec se lit dans [etat]. */ suspend fun telecharger(modele: ModeleLocal) { publier(modele, EtatModele.EnCours(0)) val resultat = withContext(Dispatchers.IO) { runCatching { installer(modele) } } resultat .onSuccess { publier(modele, etatDisque(modele)) } .onFailure { e -> Log.w(TAG, "téléchargement ${modele.id}", e) publier(modele, EtatModele.Echec(e.message ?: "Téléchargement impossible")) } } fun supprimer(modele: ModeleLocal) { when (modele.type) { TypeModele.TESSDATA -> cibleTessdata(modele).delete() TypeModele.VOSK -> racineVosk(modele).deleteRecursively() } publier(modele, EtatModele.Absent) } private fun publier(modele: ModeleLocal, etat: EtatModele) { _etats.value = _etats.value + (modele.id to etat) } private fun etatDisque(modele: ModeleLocal): EtatModele = when (modele.type) { // Présence, pas égalité de taille : un traineddata peut venir des assets de la // saveur « direct » en variante `full`, ou d'un dépôt manuel par adb — dans les // deux cas il est plus lourd que l'entrée `fast` du catalogue et reste valide. // L'intégrité de ce que l'app télécharge, elle, est garantie par le SHA-256. TypeModele.TESSDATA -> cibleTessdata(modele) .takeIf { it.isFile && it.length() >= TAILLE_MINIMALE } ?.let { EtatModele.Present(it.length()) } ?: EtatModele.Absent TypeModele.VOSK -> racineVosk(modele) .takeIf { it.isDirectory && !it.listFiles().isNullOrEmpty() } ?.let { EtatModele.Present(modele.tailleOctets) } ?: EtatModele.Absent } private fun dossierTessdata() = File(filesDir, "tesseract/tessdata") private fun cibleTessdata(modele: ModeleLocal) = File(dossierTessdata(), modele.nomFichier) private fun racineVosk(modele: ModeleLocal) = File(filesDir, modele.id) /** Télécharge, vérifie, puis met en place. Toute erreur laisse le disque propre. */ private fun installer(modele: ModeleLocal) { // Le fichier partiel vit à côté de sa destination : un échec ne laisse rien // d'exploitable là où les moteurs vont chercher leurs modèles. val part = when (modele.type) { TypeModele.TESSDATA -> File(dossierTessdata(), "${modele.nomFichier}.part") TypeModele.VOSK -> File(filesDir, "${modele.nomFichier}.part") } part.parentFile?.mkdirs() try { telechargerVers(modele, part) val empreinte = sha256(part) if (!empreinte.equals(modele.sha256, ignoreCase = true)) { throw IOException( "Empreinte inattendue pour ${modele.nomFichier} — téléchargement rejeté", ) } when (modele.type) { TypeModele.TESSDATA -> installerTessdata(modele, part) TypeModele.VOSK -> installerVosk(modele, part) } } finally { part.delete() } } private fun telechargerVers(modele: ModeleLocal, destination: File) { val url = CatalogueModeles.url(modele, prefs.miroir()) val requete = Request.Builder().url(url).build() client.newCall(requete).execute().use { reponse -> if (!reponse.isSuccessful) { throw IOException("HTTP ${reponse.code} sur $url") } val corps = reponse.body ?: throw IOException("Réponse vide sur $url") val attendu = corps.contentLength().takeIf { it > 0 } ?: modele.tailleOctets corps.byteStream().use { entree -> destination.outputStream().use { sortie -> val tampon = ByteArray(64 * 1024) var cumul = 0L var dernierPourcentage = 0 while (true) { val lus = entree.read(tampon) if (lus < 0) break sortie.write(tampon, 0, lus) cumul += lus val pourcentage = ((cumul * 100) / attendu).toInt().coerceIn(0, 100) if (pourcentage >= dernierPourcentage + 5) { dernierPourcentage = pourcentage publier(modele, EtatModele.EnCours(pourcentage)) } } } } } } private fun installerTessdata(modele: ModeleLocal, part: File) { val dossier = dossierTessdata().apply { mkdirs() } val cible = File(dossier, modele.nomFichier) cible.delete() if (!part.renameTo(cible)) { part.copyTo(cible, overwrite = true) } // Marque les modèles comme fournis par l'utilisateur : la copie depuis les // assets (saveur « direct ») ne les écrasera pas au prochain lancement. File(dossier, STAMP_TESSDATA).writeText(STAMP_EXTERNAL) } private fun installerVosk(modele: ModeleLocal, archive: File) { val racine = racineVosk(modele) racine.deleteRecursively() val canonique = filesDir.canonicalPath ZipInputStream(archive.inputStream().buffered()).use { zis -> var entree = zis.nextEntry while (entree != null) { val cible = File(filesDir, entree.name) if (!cible.canonicalPath.startsWith(canonique + File.separator)) { throw IOException("Entrée d'archive hors du dossier applicatif : ${entree.name}") } if (entree.isDirectory) { cible.mkdirs() } else { cible.parentFile?.mkdirs() cible.outputStream().use { zis.copyTo(it) } } zis.closeEntry() entree = zis.nextEntry } } if (!racine.isDirectory) { throw IOException("L'archive ne contient pas le dossier ${modele.id}") } } private fun sha256(fichier: File): String { val digest = MessageDigest.getInstance("SHA-256") fichier.inputStream().use { entree -> val tampon = ByteArray(64 * 1024) while (true) { val lus = entree.read(tampon) if (lus < 0) break digest.update(tampon, 0, lus) } } return digest.digest().joinToString("") { "%02x".format(it) } } private companion object { const val TAG = "DepotModeles" const val STAMP_TESSDATA = ".card2vcf-tessdata-variant" const val STAMP_EXTERNAL = "external" /** En deçà, le fichier est un reliquat ou une page d'erreur, pas un modèle. */ const val TAILLE_MINIMALE = 10_000L } }
GitRust