kazeia/kazeia-android/app/src/main/java/com/kazeia/dist/ProvisioningManager.kt

128 lines
4.9 KiB
Kotlin

package com.kazeia.dist
import android.content.Context
import android.os.StatFs
import android.util.Log
import com.kazeia.KazeiaPaths
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
/**
* Orchestrateur du provisionnement modèles : récupère le catalogue, calcule
* le diff avec l'état local, télécharge les composants manquants ou périmés,
* et met à jour [ProvisioningState].
*
* Les fichiers sont écrits sous `getExternalFilesDir("kazeia")` ; les chemins
* `RemoteFile.local` sont relatifs à cette racine (`models/...`, `llm/...`)
* et correspondent à la résolution de [KazeiaPaths] sur tablette de production.
*/
class ProvisioningManager(context: Context) {
private val appContext = context.applicationContext
private val client = KazeiaDistClient(appContext)
/** Racine d'écriture des modèles téléchargés. */
val root: File = File(appContext.getExternalFilesDir(null), "kazeia")
private val downloader = ModelDownloader(client, root)
/** Progression rapportée pendant le provisionnement. */
data class Progress(
val componentLabel: String,
val currentFile: String,
val overallDone: Long,
val overallTotal: Long
)
sealed class Outcome {
object Success : Outcome()
data class Failed(val reason: String) : Outcome()
}
fun probe(): Boolean = client.probe()
suspend fun fetchCatalog(): Catalog? = withContext(Dispatchers.IO) { client.fetchCatalog() }
/**
* Catalogue voix — optionnel et dynamique (le clinicien ajoute des voix
* sans toucher au catalogue modèles figé). `null` si absent du Nextcloud.
*/
suspend fun fetchVoicesCatalog(): Catalog? = withContext(Dispatchers.IO) {
client.fetchCatalog(KazeiaDistClient.VOICES_CATALOG_PATH)
}
/** Espace libre (octets) sur le volume de stockage des modèles. */
fun freeBytes(): Long = runCatching {
root.mkdirs()
StatFs(root.path).availableBytes
}.getOrDefault(0L)
/**
* Heuristique hors-ligne : les modèles cœur sont-ils présents sur le
* disque résolu par [KazeiaPaths] ? Vrai sur tablette de dev (legacy
* `/data/local/tmp` peuplé) comme sur tablette de production déjà
* provisionnée — permet à l'onboarding de court-circuiter sans réseau.
*/
fun hasCoreModels(): Boolean {
// Cœur post-refonte 2026-06-17 : Speaker GGUF (q35-lmq4) + Whisper STT.
// (Le LLM .pte est devenu une alternative admin ; le TTS est provisionné
// à part — cf #286 productisation CosyVoice.)
val llm = File(KazeiaPaths.modelsDir, "q35-lmq4.gguf").exists()
val whisper = File(KazeiaPaths.modelsDir, "whisper-small-sm8750").isDirectory
return llm && whisper
}
/** Composants à (re)télécharger selon le diff catalogue ↔ état local. */
fun pending(catalog: Catalog): List<Component> =
ProvisioningState.load(appContext).missingOrOutdated(catalog)
/**
* Télécharge tous les composants manquants/périmés du catalogue.
* Met à jour [ProvisioningState] après CHAQUE composant complet — un
* arrêt en cours de route ne reperd que le composant courant.
*/
suspend fun provision(
catalog: Catalog,
onProgress: (Progress) -> Unit
): Outcome = withContext(Dispatchers.IO) {
val state = ProvisioningState.load(appContext)
val todo = state.missingOrOutdated(catalog)
if (todo.isEmpty()) return@withContext Outcome.Success
val overallTotal = todo.sumOf { it.totalSize }
var overallBase = 0L
for (comp in todo) {
for (rf in comp.files) {
val res = downloader.download(rf) { done, _ ->
onProgress(
Progress(
componentLabel = comp.label,
currentFile = rf.local.substringAfterLast('/'),
overallDone = overallBase + done,
overallTotal = overallTotal
)
)
}
if (res is ModelDownloader.Result.Failed) {
Log.w(TAG, "provision échec sur ${rf.local}: ${res.reason}")
return@withContext Outcome.Failed("${rf.local} : ${res.reason}")
}
overallBase += rf.size
}
// Composant complet et vérifié → état persisté.
state.markInstalled(comp.id, comp.version)
state.catalogVersion = catalog.catalogVersion
state.lastCheck = System.currentTimeMillis()
ProvisioningState.save(appContext, state)
Log.i(TAG, "composant « ${comp.id} » v${comp.version} installé")
}
Outcome.Success
}
companion object {
private const val TAG = "ProvisioningManager"
}
}