feat: mises à jour de DataControl poussées depuis le serveur (installation silencieuse en mode propriétaire)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
This commit is contained in:
tuxgyverandClaude Opus 5.5 committed 2026-10-25 14:59:27 +01:00
1 parent 1b5558ff8b
commit e6215ab4a7
8 files changed
+214 -5

No files matched your search

@@ -0,0 +1,190 @@
package com.jttools.datacontrol.remote
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build
import android.os.SystemClock
import androidx.core.app.NotificationCompat
import com.jttools.datacontrol.BuildConfig
import com.jttools.datacontrol.R
import com.jttools.datacontrol.data.UpdateManager
import com.jttools.datacontrol.log.AppLog
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import javax.inject.Inject
import javax.inject.Singleton
/**
* Mise à jour de DataControl poussée depuis le serveur familyd (bouton « Déployer » de l'espace parents).
*
* L'APK est téléchargé dans le tunnel, son empreinte SHA-256 vérifiée, puis confié à l'installateur
* d'Android : installation silencieuse en mode propriétaire de l'appareil ; sinon, une notification
* « Touchez pour installer » (Android impose alors une confirmation). Android refuse de toute façon un
* APK qui ne porte pas la signature de l'application installée.
*/
@Singleton
class AppDeployer @Inject constructor(
@ApplicationContext private val context: Context
) {
private var lastVersion: String? = null
private var lastAttemptAt = 0L
/** [order] : {"version", "url", "sha256", "size"} reçu dans la consigne du serveur. */
suspend fun handle(order: JSONObject, apiBase: String, report: suspend (String, String) -> Unit) =
withContext(Dispatchers.IO) {
val version = order.optString("version")
if (version.isBlank() || !UpdateManager.isNewer(version, BuildConfig.VERSION_NAME)) return@withContext
val now = SystemClock.elapsedRealtime()
if (version == lastVersion && now - lastAttemptAt < RETRY_MS) return@withContext
lastVersion = version
lastAttemptAt = now
AppLog.i("Mise à jour", "Déploiement de DataControl $version demandé par le serveur")
try {
val file = download(apiBase + order.getString("url"), order.getString("sha256").lowercase(), version)
install(file, version)
} catch (e: Exception) {
AppLog.e("Mise à jour", "Déploiement de $version impossible", e)
report("update_failed", "DataControl $version : ${e.message ?: e.javaClass.simpleName}")
}
}
private fun download(url: String, sha256: String, version: String): File {
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
dir.listFiles()?.forEach { it.delete() }
val file = File(dir, "DataControl-$version.apk")
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 60_000
}
val digest = MessageDigest.getInstance("SHA-256")
try {
if (conn.responseCode != HttpURLConnection.HTTP_OK) error("téléchargement refusé (HTTP ${conn.responseCode})")
var total = 0L
conn.inputStream.use { input ->
file.outputStream().use { out ->
val buf = ByteArray(64 * 1024)
while (true) {
val n = input.read(buf)
if (n < 0) break
total += n
if (total > MAX_SIZE) error("APK trop volumineux")
digest.update(buf, 0, n)
out.write(buf, 0, n)
}
}
}
} finally {
conn.disconnect()
}
val got = digest.digest().joinToString("") { "%02x".format(it) }
if (got != sha256) {
file.delete()
error("empreinte SHA-256 différente de celle annoncée par le serveur")
}
return file
}
private fun install(file: File, version: String) {
val installer = context.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
setAppPackageName(context.packageName)
setSize(file.length())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
}
val id = installer.createSession(params)
installer.openSession(id).use { session ->
session.openWrite("base.apk", 0, file.length()).use { out ->
file.inputStream().use { it.copyTo(out) }
session.fsync(out)
}
val result = Intent(context, InstallResultReceiver::class.java).putExtra(EXTRA_VERSION, version)
val flags = PendingIntent.FLAG_UPDATE_CURRENT or
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0)
session.commit(PendingIntent.getBroadcast(context, id, result, flags).intentSender)
}
AppLog.i("Mise à jour", "DataControl $version confié à l'installateur d'Android")
}
companion object {
const val EXTRA_VERSION = "version"
private const val RETRY_MS = 30 * 60_000L
private const val MAX_SIZE = 100L * 1024 * 1024
}
}
/** Résultat de l'installation : confirmation à demander à l'utilisateur, succès ou échec (signalé au parent). */
class InstallResultReceiver : BroadcastReceiver() {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Deps {
fun remoteSync(): RemoteSync
}
override fun onReceive(context: Context, intent: Intent) {
val version = intent.getStringExtra(AppDeployer.EXTRA_VERSION).orEmpty()
when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val confirm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
} else {
@Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT)
} ?: return
AppLog.i("Mise à jour", "DataControl $version : confirmation de l'utilisateur demandée")
notifyConfirm(context, confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), version)
}
PackageInstaller.STATUS_SUCCESS -> AppLog.i("Mise à jour", "DataControl $version installé")
else -> {
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) ?: "code $status"
AppLog.e("Mise à jour", "Installation de DataControl $version refusée : $msg")
val pending = goAsync()
val remote = EntryPointAccessors.fromApplication(context.applicationContext, Deps::class.java).remoteSync()
CoroutineScope(Dispatchers.IO).launch {
try {
remote.pushEvent("update_failed", "DataControl $version : $msg")
} finally {
pending.finish()
}
}
}
}
}
private fun notifyConfirm(context: Context, confirm: Intent, version: String) {
val nm = context.getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(NotificationChannel(CHANNEL, "Mises à jour", NotificationManager.IMPORTANCE_HIGH))
val pi = PendingIntent.getActivity(context, 0, confirm, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
nm.notify(NOTIF_ID, NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Mise à jour de DataControl $version")
.setContentText("Touchez pour installer la version envoyée par le parent.")
.setContentIntent(pi)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build())
}
private companion object {
const val CHANNEL = "datacontrol_updates"
const val NOTIF_ID = 3101
}
}