Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
191 lines
8.6 KiB
Kotlin
191 lines
8.6 KiB
Kotlin
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
|
|
}
|
|
}
|