feat(updates): MAJ manuelles pilotées depuis l'admin (plus d'auto-update)

L'app patient ne vérifie/installe plus de MAJ au lancement. OnboardingActivity
démarre directement si le cœur est présent (offline) ; le catalogue n'est
sollicité que pour la 1ʳᵉ installation. Plus de prompt APK ni de worker
d'update en arrière-plan au boot.

Côté patient :
- UpdateStatus (état live) + UpdateWorker (check / install) réutilisant
  ProvisioningManager (contenu) + ApkUpdater (APK, PackageInstaller).
- ContentProvider : call("update_check"|"update_install") enqueue le worker ;
  query /updates expose phase + dispo APK + nb composants + progression.

Côté admin (Kazeia Admin) :
- écran « Mises à jour » (nav) : bouton Vérifier + Installer, état + barre de
  progression (poll /updates), via KazeiaUpdateClient.
- L'install APK ouvre le dialogue système PackageInstaller sur la tablette.

Bump 0.2.1 / versionCode 13. Round-trip device validé (check → AVAILABLE,
1 composant GGUF 2.38 Go détecté).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kazeia Team 2026-06-18 08:27:41 +02:00
parent a2f82f69f9
commit 3d002318d5
10 changed files with 425 additions and 20 deletions

View File

@ -105,6 +105,9 @@ private fun MainShell() {
composable(AdminDestination.Telemetry.route) {
TelemetryScreen()
}
composable(AdminDestination.Updates.route) {
com.kazeia.admin.ui.updates.UpdatesScreen()
}
composable(AdminDestination.System.route) {
SystemScreen()
}

View File

@ -0,0 +1,66 @@
package com.kazeia.admin.data.source
import android.content.ContentResolver
import android.net.Uri
/**
* Client des mises à jour à la demande de l'app patient (ContentProvider
* `call(update_check|update_install)` + query `/updates`). Le provisioning
* (download modèles + APK) s'exécute côté patient ; l'admin déclenche + suit.
*/
class KazeiaUpdateClient(private val resolver: ContentResolver) {
companion object {
private const val AUTHORITY = "com.kazeia.provider"
private val ANY_URI: Uri = Uri.parse("content://$AUTHORITY/updates")
const val CALL_CHECK = "update_check"
const val CALL_INSTALL = "update_install"
}
/** Reflète [com.kazeia.dist.UpdateStatus] côté patient. */
data class UpdateInfo(
val phase: String, // IDLE/CHECKING/AVAILABLE/UPTODATE/DOWNLOADING/INSTALLING/DONE/ERROR
val appUpdateAvailable: Boolean,
val appVersionName: String,
val appSize: Long,
val contentCount: Int,
val contentSize: Long,
val progressDone: Long,
val progressTotal: Long,
val label: String,
val error: String,
val lastCheck: Long
)
/** True si l'app patient est joignable (provider présent). */
fun reachable(): Boolean = query() != null
fun check(): Boolean = call(CALL_CHECK)
fun install(): Boolean = call(CALL_INSTALL)
private fun call(method: String): Boolean = runCatching {
resolver.call(ANY_URI, method, null, null)?.getBoolean("accepted", false) ?: false
}.getOrDefault(false)
fun query(): UpdateInfo? = runCatching {
resolver.query(ANY_URI, null, null, null, null)?.use { c ->
if (!c.moveToFirst()) return@use null
fun s(n: String) = c.getString(c.getColumnIndexOrThrow(n))
fun i(n: String) = c.getInt(c.getColumnIndexOrThrow(n))
fun l(n: String) = c.getLong(c.getColumnIndexOrThrow(n))
UpdateInfo(
phase = s("phase"),
appUpdateAvailable = i("app_update_available") == 1,
appVersionName = s("app_version_name"),
appSize = l("app_size"),
contentCount = i("content_count"),
contentSize = l("content_size"),
progressDone = l("progress_done"),
progressTotal = l("progress_total"),
label = s("label"),
error = s("error"),
lastCheck = l("last_check")
)
}
}.getOrNull()
}

View File

@ -10,6 +10,7 @@ import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.People
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material.icons.outlined.RecordVoiceOver
import androidx.compose.material.icons.outlined.SystemUpdate
import androidx.compose.ui.graphics.vector.ImageVector
/**
@ -28,6 +29,7 @@ enum class AdminDestination(
Rag( "rag", com.kazeia.admin.R.string.nav_rag, Icons.Outlined.Book),
History( "history", com.kazeia.admin.R.string.nav_history, Icons.Outlined.History),
Telemetry("telemetry", com.kazeia.admin.R.string.nav_telemetry, Icons.Outlined.Analytics),
Updates( "updates", com.kazeia.admin.R.string.nav_updates, Icons.Outlined.SystemUpdate),
System( "system", com.kazeia.admin.R.string.nav_system, Icons.Outlined.Build);
companion object {

View File

@ -0,0 +1,157 @@
package com.kazeia.admin.ui.updates
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.kazeia.admin.data.source.KazeiaUpdateClient
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UpdatesScreen() {
val context = LocalContext.current
val client = remember { KazeiaUpdateClient(context.contentResolver) }
var info by remember { mutableStateOf<KazeiaUpdateClient.UpdateInfo?>(null) }
var reachable by remember { mutableStateOf(true) }
// Poll continu de l'état (le worker tourne côté patient).
LaunchedEffect(Unit) {
while (true) {
val q = client.query()
reachable = q != null
info = q
delay(1000)
}
}
val phase = info?.phase ?: "IDLE"
val busy = phase == "CHECKING" || phase == "DOWNLOADING" || phase == "INSTALLING"
Scaffold(
topBar = {
TopAppBar(
title = { Text("Mises à jour") },
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.background)
)
}
) { padding ->
Column(
modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (!reachable) {
Text("Kazeia patient non joignable.", style = MaterialTheme.typography.titleMedium)
Text("Lance l'app Kazeia puis reviens ici.", color = MaterialTheme.colorScheme.onSurfaceVariant)
return@Column
}
StatusCard(info, busy)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(enabled = !busy, onClick = { client.check() }) {
Text("Vérifier les mises à jour")
}
val hasUpdate = info?.let { it.appUpdateAvailable || it.contentCount > 0 } == true
Button(enabled = !busy && hasUpdate, onClick = { client.install() }) {
Text("Installer")
}
}
Text(
"Les mises à jour ne se font plus automatiquement : elles se déclenchent " +
"uniquement ici. L'installation de l'application affichera un dialogue " +
"système à confirmer sur la tablette.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun StatusCard(info: KazeiaUpdateClient.UpdateInfo?, busy: Boolean) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
shape = RoundedCornerShape(12.dp)
) {
Column(Modifier.padding(16.dp)) {
val phase = info?.phase ?: "IDLE"
val (title, detail) = when (phase) {
"IDLE" -> "Aucune vérification" to "Touchez « Vérifier » pour interroger le serveur."
"CHECKING" -> "Vérification en cours…" to ""
"UPTODATE" -> "Tout est à jour ✓" to "Aucune mise à jour disponible."
"AVAILABLE" -> "Mises à jour disponibles" to buildAvailable(info)
"DOWNLOADING" -> "Téléchargement…" to (info?.label ?: "")
"INSTALLING" -> "Installation…" to (info?.label ?: "")
"DONE" -> "Mise à jour terminée ✓" to "L'application redémarrera si elle a été mise à jour."
"ERROR" -> "Échec" to (info?.error ?: "Erreur inconnue.")
else -> phase to ""
}
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
if (detail.isNotBlank()) {
Spacer(Modifier.height(4.dp))
Text(detail, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (busy) {
Spacer(Modifier.height(12.dp))
val total = info?.progressTotal ?: 0L
val done = info?.progressDone ?: 0L
if (total > 0L) {
LinearProgressIndicator(
progress = { (done.toFloat() / total).coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(4.dp))
Text("${human(done)} / ${human(total)}", style = MaterialTheme.typography.bodySmall)
} else {
LinearProgressIndicator(Modifier.fillMaxWidth())
}
}
}
}
}
private fun buildAvailable(info: KazeiaUpdateClient.UpdateInfo?): String {
if (info == null) return ""
val parts = mutableListOf<String>()
if (info.appUpdateAvailable) parts += "Application v${info.appVersionName} (${human(info.appSize)})"
if (info.contentCount > 0) parts += "${info.contentCount} composant(s) modèle/voix (${human(info.contentSize)})"
return parts.joinToString("\n")
}
private fun human(bytes: Long): String {
if (bytes < 1024) return "$bytes o"
val kb = bytes / 1024.0
if (kb < 1024) return "%.0f Ko".format(kb)
val mb = kb / 1024.0
if (mb < 1024) return "%.1f Mo".format(mb)
return "%.2f Go".format(mb / 1024.0)
}

View File

@ -7,6 +7,7 @@
<string name="nav_profiles">Profils patients</string>
<string name="nav_prompts">Prompts</string>
<string name="nav_parameters">Paramètres</string>
<string name="nav_updates">Mises à jour</string>
<string name="nav_rag">Base documentaire</string>
<string name="nav_history">Historique</string>
<string name="nav_telemetry">Telemetry</string>

View File

@ -44,8 +44,8 @@ android {
applicationId = "com.kazeia"
minSdk = 28
targetSdk = 36
versionCode = 12
versionName = "0.2.0"
versionCode = 13
versionName = "0.2.1"
// WebDAV de distribution — injecté depuis local.properties.
buildConfigField("String", "WEBDAV_BASE", "\"${localProp("webdav.base")}\"")

View File

@ -0,0 +1,30 @@
package com.kazeia.dist
/**
* État live des mises à jour, piloté à la demande depuis l'app admin (plus
* d'update automatique au lancement cf OnboardingActivity).
*
* Singleton en mémoire : le [UpdateWorker] (process patient) écrit, le
* ContentProvider lit (même process). Si le process meurt entre deux étapes,
* l'état repart à IDLE l'admin n'a qu'à relancer « Vérifier ». Suffisant
* pour le flux check poll install poll piloté côté admin.
*/
object UpdateStatus {
enum class Phase { IDLE, CHECKING, AVAILABLE, UPTODATE, DOWNLOADING, INSTALLING, DONE, ERROR }
@Volatile var phase: Phase = Phase.IDLE
@Volatile var appUpdateAvailable: Boolean = false
@Volatile var appVersionName: String = ""
@Volatile var appSize: Long = 0L
@Volatile var contentCount: Int = 0 // composants modèles/voix à (re)télécharger
@Volatile var contentSize: Long = 0L
@Volatile var progressDone: Long = 0L
@Volatile var progressTotal: Long = 0L
@Volatile var label: String = ""
@Volatile var error: String = ""
@Volatile var lastCheck: Long = 0L
fun resetForRun() {
progressDone = 0L; progressTotal = 0L; label = ""; error = ""
}
}

View File

@ -0,0 +1,102 @@
package com.kazeia.dist
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
/**
* Worker de mise à jour À LA DEMANDE (déclenché par l'app admin via le
* ContentProvider `call("update_check")` / `call("update_install")`).
*
* - mode CHECK : récupère le catalogue, calcule ce qui est dispo (APK +
* composants modèles/voix périmés) publie dans [UpdateStatus].
* - mode INSTALL : télécharge les composants périmés, puis (si dispo) télécharge
* l'APK et lance l'installation système (PackageInstaller).
*
* Réutilise [ProvisioningManager] (contenu) et [ApkUpdater] (APK). Aucune
* mise à jour automatique au lancement : tout passe par ici.
*/
class UpdateWorker(
private val appContext: Context,
params: WorkerParameters
) : CoroutineWorker(appContext, params) {
private val manager = ProvisioningManager(appContext)
private val apk = ApkUpdater(appContext)
override suspend fun doWork(): Result {
val install = inputData.getString(KEY_MODE) == MODE_INSTALL
UpdateStatus.resetForRun()
UpdateStatus.phase = UpdateStatus.Phase.CHECKING
val catalog = manager.fetchCatalog() ?: run {
UpdateStatus.phase = UpdateStatus.Phase.ERROR
UpdateStatus.error = "Serveur de mise à jour injoignable."
return Result.failure()
}
val voices = manager.fetchVoicesCatalog()
val appUpd = apk.availableUpdate(catalog)
val pending = manager.pending(catalog) + (voices?.let { manager.pending(it) } ?: emptyList())
UpdateStatus.appUpdateAvailable = appUpd != null
UpdateStatus.appVersionName = appUpd?.versionName ?: ""
UpdateStatus.appSize = appUpd?.size ?: 0L
UpdateStatus.contentCount = pending.size
UpdateStatus.contentSize = pending.sumOf { it.totalSize }
UpdateStatus.lastCheck = System.currentTimeMillis()
if (!install) {
UpdateStatus.phase = if (appUpd == null && pending.isEmpty())
UpdateStatus.Phase.UPTODATE else UpdateStatus.Phase.AVAILABLE
return Result.success()
}
// ── INSTALL ──────────────────────────────────────────────────────────
// 1. Contenu (modèles + voix), s'il y en a.
if (pending.isNotEmpty()) {
UpdateStatus.phase = UpdateStatus.Phase.DOWNLOADING
val res = manager.provision(catalog) { p ->
UpdateStatus.label = p.componentLabel
UpdateStatus.progressDone = p.overallDone
UpdateStatus.progressTotal = p.overallTotal
}
if (res is ProvisioningManager.Outcome.Failed) {
UpdateStatus.phase = UpdateStatus.Phase.ERROR
UpdateStatus.error = res.reason
return Result.failure()
}
if (voices != null) manager.provision(voices) { p ->
UpdateStatus.label = p.componentLabel
UpdateStatus.progressDone = p.overallDone
UpdateStatus.progressTotal = p.overallTotal
}
}
// 2. APK — téléchargement + installation système (dialogue PackageInstaller).
if (appUpd != null) {
UpdateStatus.phase = UpdateStatus.Phase.INSTALLING
UpdateStatus.label = "Téléchargement de l'application v${appUpd.versionName}"
val file = apk.downloadApk(appUpd) { done, total ->
UpdateStatus.progressDone = done; UpdateStatus.progressTotal = total
}
if (file == null) {
UpdateStatus.phase = UpdateStatus.Phase.ERROR
UpdateStatus.error = "Échec du téléchargement de l'APK."
return Result.failure()
}
UpdateStatus.label = "Installation — confirmez le dialogue système."
apk.install(file) // ouvre le dialogue d'installation sur la tablette
}
UpdateStatus.phase = UpdateStatus.Phase.DONE
return Result.success()
}
companion object {
const val NAME = "kazeia_update"
const val KEY_MODE = "mode"
const val MODE_CHECK = "check"
const val MODE_INSTALL = "install"
}
}

View File

@ -6,6 +6,7 @@ import android.content.UriMatcher
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.os.Bundle
import android.os.Debug
import android.os.Process
import java.io.File
@ -42,6 +43,10 @@ class KazeiaTelemetryProvider : ContentProvider() {
const val PATH_RAG = "rag"
const val PATH_RAG_STATUS = "rag_status"
const val PATH_RAG_QUERY = "rag_query"
const val PATH_UPDATES = "updates"
// Méthodes ContentProvider.call() pour les MAJ à la demande (pilotées admin).
const val CALL_UPDATE_CHECK = "update_check"
const val CALL_UPDATE_INSTALL = "update_install"
const val ACTION_RELOAD_CONFIG = "com.kazeia.action.RELOAD_CONFIG"
const val ACTION_RELOAD_PROFILES = "com.kazeia.action.RELOAD_PROFILES"
const val ACTION_RELOAD_RAG = "com.kazeia.action.RELOAD_RAG"
@ -64,6 +69,7 @@ class KazeiaTelemetryProvider : ContentProvider() {
private const val CODE_RAG_ITEM = 16
private const val CODE_RAG_STATUS = 17
private const val CODE_RAG_QUERY = 18
private const val CODE_UPDATES = 19
// Emplacements canoniques — résolus par KazeiaPaths (legacy /data/local/tmp
// sur tablette dev, stockage externe app sinon). Non-const.
@ -97,6 +103,7 @@ class KazeiaTelemetryProvider : ContentProvider() {
addURI(AUTHORITY, "$PATH_RAG/*", CODE_RAG_ITEM)
addURI(AUTHORITY, PATH_RAG_STATUS, CODE_RAG_STATUS)
addURI(AUTHORITY, PATH_RAG_QUERY, CODE_RAG_QUERY)
addURI(AUTHORITY, PATH_UPDATES, CODE_UPDATES)
}
override fun onCreate(): Boolean = true
@ -124,9 +131,49 @@ class KazeiaTelemetryProvider : ContentProvider() {
CODE_RAG_ITEM -> ragDocCursor(uri.lastPathSegment ?: "")
CODE_RAG_STATUS -> ragStatusCursor()
CODE_RAG_QUERY -> ragQueryCursor(selectionArgs?.getOrNull(0) ?: selection)
CODE_UPDATES -> updatesCursor()
else -> null
}
/** État live des mises à jour (lu par l'admin pendant check/install). */
private fun updatesCursor(): Cursor {
val s = com.kazeia.dist.UpdateStatus
val cols = arrayOf(
"phase", "app_update_available", "app_version_name", "app_size",
"content_count", "content_size", "progress_done", "progress_total",
"label", "error", "last_check"
)
return MatrixCursor(cols).apply {
addRow(arrayOf(
s.phase.name,
if (s.appUpdateAvailable) 1 else 0,
s.appVersionName, s.appSize,
s.contentCount, s.contentSize,
s.progressDone, s.progressTotal,
s.label, s.error, s.lastCheck
))
}
}
/** RPC pilotées admin : déclenche un check ou une installation de MAJ. */
override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
val ctx = context ?: return null
val mode = when (method) {
CALL_UPDATE_CHECK -> com.kazeia.dist.UpdateWorker.MODE_CHECK
CALL_UPDATE_INSTALL -> com.kazeia.dist.UpdateWorker.MODE_INSTALL
else -> return null
}
val req = androidx.work.OneTimeWorkRequestBuilder<com.kazeia.dist.UpdateWorker>()
.setInputData(androidx.work.workDataOf(com.kazeia.dist.UpdateWorker.KEY_MODE to mode))
.build()
androidx.work.WorkManager.getInstance(ctx).enqueueUniqueWork(
com.kazeia.dist.UpdateWorker.NAME,
androidx.work.ExistingWorkPolicy.REPLACE,
req
)
return Bundle().apply { putBoolean("accepted", true) }
}
private fun ragStatusCursor(): Cursor {
val ctx = context ?: return MatrixCursor(arrayOf<String>())
val cols = arrayOf("enabled", "ready", "model", "dim", "doc_count", "chunk_count", "pending")

View File

@ -78,30 +78,27 @@ class OnboardingActivity : AppCompatActivity() {
/** Point d'entrée : récupère le catalogue puis décide de la suite. */
private fun begin() {
resetUi()
tvStatus.text = "Vérification du catalogue…"
// Les mises à jour sont désormais MANUELLES (pilotées depuis l'app admin :
// écran « Mises à jour », bouton Vérifier/Installer). Au lancement on ne
// consulte plus le catalogue ni l'APK : si le cœur est présent, on démarre
// directement (offline, zéro auto-update). Le catalogue n'est sollicité que
// pour la PREMIÈRE installation (cœur absent) ou un re-provisionnement forcé.
if (!force && manager.hasCoreModels()) {
Log.i(TAG, "modèles présents → Splash (pas d'auto-update)")
goToSplash()
return
}
tvStatus.text = "Première installation…"
lifecycleScope.launch {
val catalog = withContext(Dispatchers.IO) { manager.fetchCatalog() }
if (catalog == null) {
// Hors-ligne : un install qui tourne déjà peut continuer.
if (!force && manager.hasCoreModels()) {
Log.i(TAG, "catalogue injoignable mais modèles présents → Splash")
goToSplash()
} else {
showError("Connexion requise pour la première installation.\n" +
"Vérifiez le réseau puis réessayez.")
}
if (!force && manager.hasCoreModels()) goToSplash()
else showError("Connexion requise pour la première installation.\n" +
"Vérifiez le réseau puis réessayez.")
return@launch
}
// 1. Mise à jour de l'app elle-même.
val update = apkUpdater.availableUpdate(catalog)
if (update != null) {
promptApkUpdate(update, catalog)
return@launch
}
continueWithModels(catalog)
continueWithModels(catalog) // télécharge les modèles manquants (sans prompt APK)
}
}