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:
1 parent
1b5558ff8b
commit
e6215ab4a7
8 files changed
+214
-5
No files matched your search
@@ -113,12 +113,18 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Résultat des mises à jour poussées par le serveur (PackageInstaller). -->
|
||||
<receiver
|
||||
android:name=".remote.InstallResultReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".service.BootReceiver"
|
||||
android:exported="true"
|
||||
android:enabled="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,8 @@ import javax.inject.Singleton
|
||||
class RemoteSync @Inject constructor(
|
||||
private val settings: SettingsRepository,
|
||||
private val installedApps: InstalledAppsRepository,
|
||||
private val tunnel: TunnelManager
|
||||
private val tunnel: TunnelManager,
|
||||
private val deployer: AppDeployer
|
||||
) {
|
||||
private val flushLock = Mutex()
|
||||
private var started = false
|
||||
@@ -98,6 +99,10 @@ class RemoteSync @Inject constructor(
|
||||
settings.applyServerPolicy(version, mode, reason, rules)
|
||||
if (mode != current.serverMode) AppLog.i("Liaison", "Mode réseau : $mode${if (reason.isNotBlank()) " ($reason)" else ""}")
|
||||
}
|
||||
// Mise à jour de DataControl poussée par le parent.
|
||||
p.optJSONObject("deploy")?.let { order ->
|
||||
current.link?.let { link -> deployer.handle(order, link.apiBase) { t, d -> pushEvent(t, d) } }
|
||||
}
|
||||
flushEvents()
|
||||
true
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ class BootReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
if (intent?.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
// Redémarrage de l'appareil, ou DataControl vient d'être mis à jour (déploiement depuis le serveur).
|
||||
if (intent?.action != Intent.ACTION_BOOT_COMPLETED && intent?.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
|
||||
val appContext = context.applicationContext
|
||||
val entryPoint = EntryPointAccessors.fromApplication(appContext, BootReceiverEntryPoint::class.java)
|
||||
val pendingResult = goAsync()
|
||||
|
||||
@@ -31,6 +31,7 @@ object AboutContent {
|
||||
AboutEntry("Anti-désinstallation", "DataControl peut devenir administrateur de l'appareil : Android refuse alors de le désinstaller tant que la protection est active."),
|
||||
AboutEntry("Journal de diagnostic", "État du tunnel, mode appliqué et, si le service d'accessibilité est activé, chaque application utilisée. Conservé 15 jours, avec copie et partage."),
|
||||
AboutEntry("Mise à jour intégrée", "Recherche et installation de la dernière version publiée, depuis l'onglet Journal de cette page."),
|
||||
AboutEntry("Mise à jour poussée par le parent", "Depuis l'espace parents : la nouvelle version arrive par le tunnel, son empreinte est vérifiée, puis elle s'installe toute seule en mode propriétaire (sinon, touchez la notification « Mise à jour de DataControl »)."),
|
||||
AboutEntry("Reprise automatique", "La surveillance et le tunnel redémarrent après un redémarrage de l'appareil, et sont relancés périodiquement si le système les a arrêtés.")
|
||||
))
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user