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