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
    }
}