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
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.4.0 – 2026-10-25
|
||||||
|
|
||||||
|
- ✨ **Ajouté** — **Mises à jour poussées depuis le serveur** : depuis l'espace parents (bouton « Déployer » ou « Mettre à jour DataControl partout »), la nouvelle version est téléchargée par le tunnel, son empreinte vérifiée, puis installée — sans aucune action sur le téléphone en mode propriétaire, sinon après un toucher sur la notification « Mise à jour de DataControl ».
|
||||||
|
- 🛠️ **Modifié** — DataControl redémarre sa surveillance tout seul juste après une mise à jour.
|
||||||
|
|
||||||
## v0.3.0 – 2026-10-25
|
## v0.3.0 – 2026-10-25
|
||||||
|
|
||||||
- ✨ **Ajouté** — **Provisionnement du mode propriétaire par QR code**, sans PC ni débogage USB : sur un téléphone réinitialisé, toucher 6 fois l'écran d'accueil puis scanner le QR créé sur l'espace parents. DataControl est installé, devient propriétaire de l'appareil, s'associe tout seul au serveur et active la protection.
|
- ✨ **Ajouté** — **Provisionnement du mode propriétaire par QR code**, sans PC ni débogage USB : sur un téléphone réinitialisé, toucher 6 fois l'écran d'accueil puis scanner le QR créé sur l'espace parents. DataControl est installé, devient propriétaire de l'appareil, s'associe tout seul au serveur et active la protection.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# DataControl
|
# DataControl
|
||||||
|
|
||||||
🚀 v0.3.0 · 🤖 Android · 🟣 Kotlin · 🎨 Jetpack Compose · 🔒 WireGuard · 🛡️ mode propriétaire · 👨👩👧 contrôle parental · 🧪 tests · 📄 licence non déclarée
|
🚀 v0.4.0 · 🤖 Android · 🟣 Kotlin · 🎨 Jetpack Compose · 🔒 WireGuard · 🛡️ mode propriétaire · 👨👩👧 contrôle parental · 🧪 tests · 📄 licence non déclarée
|
||||||
|
|
||||||
**DataControl** est l'application Android du téléphone de l'enfant. Elle relie le téléphone en permanence, par un tunnel **WireGuard**, au serveur de contrôle parental **familyd** du parent : tout le trafic (Wi-Fi comme données mobiles) passe par ce serveur, qui décide de ce qui passe. Sur le téléphone, elle bloque les applications interdites, compte le temps d'écran et suit la consommation de données mobiles.
|
**DataControl** est l'application Android du téléphone de l'enfant. Elle relie le téléphone en permanence, par un tunnel **WireGuard**, au serveur de contrôle parental **familyd** du parent : tout le trafic (Wi-Fi comme données mobiles) passe par ce serveur, qui décide de ce qui passe. Sur le téléphone, elle bloque les applications interdites, compte le temps d'écran et suit la consommation de données mobiles.
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
- **Mot de passe de verrouillage** — Protège les Réglages, l'effacement du journal, la désactivation de la protection et la sortie du mode propriétaire. Haché avec PBKDF2 ; après 3 essais ratés, attente croissante (30 s à 15 min).
|
- **Mot de passe de verrouillage** — Protège les Réglages, l'effacement du journal, la désactivation de la protection et la sortie du mode propriétaire. Haché avec PBKDF2 ; après 3 essais ratés, attente croissante (30 s à 15 min).
|
||||||
- **Journal de diagnostic** — État du tunnel, mode appliqué et, avec le service d'accessibilité, chaque application utilisée. Conservé 15 jours.
|
- **Journal de diagnostic** — État du tunnel, mode appliqué et, avec le service d'accessibilité, chaque application utilisée. Conservé 15 jours.
|
||||||
- **Mise à jour intégrée** — Dernière Release Gitea depuis l'onglet *À propos* ; le serveur familyd la propose aussi aux appareils du tunnel sur `http://10.78.0.1/installer`.
|
- **Mise à jour intégrée** — Dernière Release Gitea depuis l'onglet *À propos* ; le serveur familyd la propose aussi aux appareils du tunnel sur `http://10.78.0.1/installer`.
|
||||||
|
- **Déploiement depuis le serveur** — Le parent pousse la nouvelle version depuis l'espace parents : téléchargement par le tunnel, empreinte SHA-256 vérifiée, installation silencieuse en mode propriétaire (sinon un toucher sur la notification).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -113,12 +113,18 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- Résultat des mises à jour poussées par le serveur (PackageInstaller). -->
|
||||||
|
<receiver
|
||||||
|
android:name=".remote.InstallResultReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".service.BootReceiver"
|
android:name=".service.BootReceiver"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:enabled="true">
|
android:enabled="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</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(
|
class RemoteSync @Inject constructor(
|
||||||
private val settings: SettingsRepository,
|
private val settings: SettingsRepository,
|
||||||
private val installedApps: InstalledAppsRepository,
|
private val installedApps: InstalledAppsRepository,
|
||||||
private val tunnel: TunnelManager
|
private val tunnel: TunnelManager,
|
||||||
|
private val deployer: AppDeployer
|
||||||
) {
|
) {
|
||||||
private val flushLock = Mutex()
|
private val flushLock = Mutex()
|
||||||
private var started = false
|
private var started = false
|
||||||
@@ -98,6 +99,10 @@ class RemoteSync @Inject constructor(
|
|||||||
settings.applyServerPolicy(version, mode, reason, rules)
|
settings.applyServerPolicy(version, mode, reason, rules)
|
||||||
if (mode != current.serverMode) AppLog.i("Liaison", "Mode réseau : $mode${if (reason.isNotBlank()) " ($reason)" else ""}")
|
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()
|
flushEvents()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ class BootReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent?) {
|
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 appContext = context.applicationContext
|
||||||
val entryPoint = EntryPointAccessors.fromApplication(appContext, BootReceiverEntryPoint::class.java)
|
val entryPoint = EntryPointAccessors.fromApplication(appContext, BootReceiverEntryPoint::class.java)
|
||||||
val pendingResult = goAsync()
|
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("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("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 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.")
|
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.")
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
versionName=0.3.0
|
versionName=0.4.0
|
||||||
versionCode=13
|
versionCode=14
|
||||||
Reference in new issue
Block a user