Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b138a15513 | ||
|
|
303d2c4cdc | ||
|
|
36c829c14d | ||
|
|
ed8be296ba | ||
|
|
7af250cc1c | ||
|
|
e6215ab4a7 |
No files matched your search
@@ -1,5 +1,24 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.7.0 – 2026-10-25
|
||||||
|
|
||||||
|
- ✨ **Ajouté** — **Déploiement forcé** depuis l'espace parents : réinstalle DataControl via le tunnel WireGuard même si la version proposée n'est pas plus récente que celle installée (réparation / réinstallation à distance).
|
||||||
|
|
||||||
|
## v0.6.0 – 2026-10-25
|
||||||
|
|
||||||
|
- ✨ **Ajouté** — **Faire sonner le téléphone** depuis l'espace parents : une sonnerie d'alarme retentit à plein volume (même si le téléphone est en silencieux), avec vibration et une notification « touchez pour arrêter » ; la sonnerie s'arrête d'elle-même au bout de la durée demandée, ou quand le parent l'arrête.
|
||||||
|
- 🛠️ **Modifié** — Chaque action à distance (verrouillage, son, partage d'écran, sonnerie) est désormais acquittée au serveur — exécutée ou en échec, avec le détail — ce qui complète l'audit des commandes côté espace parents.
|
||||||
|
|
||||||
|
## v0.5.0 – 2026-10-25
|
||||||
|
|
||||||
|
- ✨ **Ajouté** — **Actions à distance depuis l'espace parents** (téléphones DataControl) : voir l'écran en direct, verrouiller / mettre en veille, couper ou rétablir le son.
|
||||||
|
- ✨ **Ajouté** — Partage d'écran par le service d'accessibilité (Android 11+), image réduite envoyée par le tunnel ; un bandeau rouge « Écran partagé avec le parent » reste affiché sur le téléphone pendant toute la durée, et le partage s'arrête de lui-même au bout de 5 minutes.
|
||||||
|
|
||||||
|
## 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.7.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,8 @@
|
|||||||
- **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).
|
||||||
|
- **Actions à distance** — Depuis l'espace parents : voir l'écran en direct (service d'accessibilité, Android 11+, avec bandeau visible « Écran partagé » sur le téléphone), verrouiller / mettre en veille, couper ou rétablir le son.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
@@ -113,12 +114,23 @@
|
|||||||
</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" />
|
||||||
|
|
||||||
|
<!-- Arrêt de la sonnerie « Faire sonner » depuis la notification. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".remote.RingStopReceiver"
|
||||||
|
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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
package com.jttools.datacontrol.guard
|
package com.jttools.datacontrol.guard
|
||||||
|
|
||||||
import android.accessibilityservice.AccessibilityService
|
import android.accessibilityservice.AccessibilityService
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.PixelFormat
|
||||||
|
import android.os.Build
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.WindowManager
|
||||||
|
import android.widget.TextView
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -13,6 +20,8 @@ import android.telecom.TelecomManager
|
|||||||
import android.view.accessibility.AccessibilityEvent
|
import android.view.accessibility.AccessibilityEvent
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import com.jttools.datacontrol.log.AppLog
|
import com.jttools.datacontrol.log.AppLog
|
||||||
|
import com.jttools.datacontrol.remote.RemoteControl
|
||||||
|
import com.jttools.datacontrol.remote.RemoteSync
|
||||||
import com.jttools.datacontrol.service.MonitorStateHolder
|
import com.jttools.datacontrol.service.MonitorStateHolder
|
||||||
import com.jttools.datacontrol.service.NetworkTransport
|
import com.jttools.datacontrol.service.NetworkTransport
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
@@ -22,8 +31,11 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Détecte l'application au premier plan (service d'accessibilité, sans lire le contenu des écrans)
|
* Détecte l'application au premier plan (service d'accessibilité, sans lire le contenu des écrans)
|
||||||
@@ -35,6 +47,10 @@ class AppGuardService : AccessibilityService() {
|
|||||||
|
|
||||||
@Inject lateinit var engine: GuardEngine
|
@Inject lateinit var engine: GuardEngine
|
||||||
@Inject lateinit var stateHolder: MonitorStateHolder
|
@Inject lateinit var stateHolder: MonitorStateHolder
|
||||||
|
@Inject lateinit var remoteControl: RemoteControl
|
||||||
|
@Inject lateinit var remoteSync: RemoteSync
|
||||||
|
|
||||||
|
private var shareBanner: TextView? = null
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
private var exempt: Set<String> = emptySet()
|
private var exempt: Set<String> = emptySet()
|
||||||
@@ -65,6 +81,94 @@ class AppGuardService : AccessibilityService() {
|
|||||||
currentPkg?.let { enforce(it) }
|
currentPkg?.let { enforce(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
startScreenShareLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partage d'écran demandé par le parent : capture l'écran (API 30+) et l'envoie au serveur, tant que
|
||||||
|
* [RemoteControl.screenShareUntil] n'est pas atteint. Un bandeau « Écran partagé » reste affiché.
|
||||||
|
*/
|
||||||
|
private fun startScreenShareLoop() = scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
if (!remoteControl.screenSharing) {
|
||||||
|
hideBanner()
|
||||||
|
delay(1_000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||||
|
AppLog.w("Écran", "Partage d'écran indisponible (Android 11 minimum)")
|
||||||
|
remoteSync.pushEvent("screen_unsupported", "Partage d'écran impossible : Android 11 minimum")
|
||||||
|
delay(5_000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
showBanner()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
val jpeg = captureJpeg()
|
||||||
|
if (jpeg != null) remoteSync.pushScreen(jpeg)
|
||||||
|
}
|
||||||
|
delay(SCREEN_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@androidx.annotation.RequiresApi(Build.VERSION_CODES.R)
|
||||||
|
private suspend fun captureJpeg(): ByteArray? {
|
||||||
|
val bitmap = suspendCancellableCoroutine<Bitmap?> { cont ->
|
||||||
|
runCatching {
|
||||||
|
takeScreenshot(android.view.Display.DEFAULT_DISPLAY, mainExecutor,
|
||||||
|
object : TakeScreenshotCallback {
|
||||||
|
override fun onSuccess(result: ScreenshotResult) {
|
||||||
|
val bmp = runCatching {
|
||||||
|
Bitmap.wrapHardwareBuffer(result.hardwareBuffer, result.colorSpace)
|
||||||
|
?.copy(Bitmap.Config.ARGB_8888, false)
|
||||||
|
}.getOrNull()
|
||||||
|
result.hardwareBuffer.close()
|
||||||
|
if (cont.isActive) cont.resume(bmp)
|
||||||
|
}
|
||||||
|
override fun onFailure(errorCode: Int) {
|
||||||
|
if (cont.isActive) cont.resume(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}.onFailure { if (cont.isActive) cont.resume(null) }
|
||||||
|
} ?: return null
|
||||||
|
return runCatching {
|
||||||
|
val scaled = scaleDown(bitmap, MAX_WIDTH)
|
||||||
|
if (scaled !== bitmap) bitmap.recycle()
|
||||||
|
ByteArrayOutputStream().use { out ->
|
||||||
|
scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
|
||||||
|
scaled.recycle()
|
||||||
|
out.toByteArray()
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scaleDown(bmp: Bitmap, maxWidth: Int): Bitmap {
|
||||||
|
if (bmp.width <= maxWidth) return bmp
|
||||||
|
val h = bmp.height * maxWidth / bmp.width
|
||||||
|
return Bitmap.createScaledBitmap(bmp, maxWidth, h, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showBanner() {
|
||||||
|
if (shareBanner != null) return
|
||||||
|
val wm = getSystemService(WindowManager::class.java)
|
||||||
|
val tv = TextView(this).apply {
|
||||||
|
text = getString(com.jttools.datacontrol.R.string.screen_shared_banner)
|
||||||
|
setBackgroundColor(Color.parseColor("#C62828"))
|
||||||
|
setTextColor(Color.WHITE)
|
||||||
|
setPadding(24, 12, 24, 12)
|
||||||
|
textSize = 13f
|
||||||
|
}
|
||||||
|
val lp = WindowManager.LayoutParams(
|
||||||
|
WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.WRAP_CONTENT,
|
||||||
|
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
|
||||||
|
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
|
||||||
|
PixelFormat.TRANSLUCENT
|
||||||
|
).apply { gravity = Gravity.TOP }
|
||||||
|
runCatching { wm.addView(tv, lp); shareBanner = tv }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hideBanner() {
|
||||||
|
shareBanner?.let { runCatching { getSystemService(WindowManager::class.java).removeView(it) } }
|
||||||
|
shareBanner = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
||||||
@@ -152,6 +256,7 @@ class AppGuardService : AccessibilityService() {
|
|||||||
override fun onInterrupt() = Unit
|
override fun onInterrupt() = Unit
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
hideBanner()
|
||||||
runCatching { unregisterReceiver(screenReceiver) }
|
runCatching { unregisterReceiver(screenReceiver) }
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
@@ -160,6 +265,9 @@ class AppGuardService : AccessibilityService() {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val CHECK_INTERVAL_MS = 10_000L
|
private const val CHECK_INTERVAL_MS = 10_000L
|
||||||
private const val EXEMPT_TTL_MS = 5 * 60_000L
|
private const val EXEMPT_TTL_MS = 5 * 60_000L
|
||||||
|
private const val SCREEN_INTERVAL_MS = 1_500L
|
||||||
|
private const val MAX_WIDTH = 720
|
||||||
|
private const val JPEG_QUALITY = 50
|
||||||
|
|
||||||
fun isEnabled(context: Context): Boolean {
|
fun isEnabled(context: Context): Boolean {
|
||||||
val enabled = Settings.Secure.getString(
|
val enabled = Settings.Secure.getString(
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
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()) return@withContext
|
||||||
|
// Déploiement forcé (option du parent) : réinstalle même version comprise, en ignorant les garde-fous.
|
||||||
|
val force = order.optBoolean("force", false)
|
||||||
|
if (!force && !UpdateManager.isNewer(version, BuildConfig.VERSION_NAME)) return@withContext
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
if (!force && 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" + if (force) " (forcé)" else "")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,22 @@ class FamilyApi(private val apiBase: String, private val token: String) {
|
|||||||
request("POST", "$apiBase/api/device/apps", token, JSONObject().put("apps", apps))
|
request("POST", "$apiBase/api/device/apps", token, JSONObject().put("apps", apps))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Attente longue des commandes du parent (verrouillage, son, partage d'écran). */
|
||||||
|
fun commands(waitSeconds: Int): JSONArray =
|
||||||
|
JSONObject(request("GET", "$apiBase/api/device/commands?wait=$waitSeconds", token, null, waitSeconds * 1000 + 5000))
|
||||||
|
.optJSONArray("commands") ?: JSONArray()
|
||||||
|
|
||||||
|
/** Envoie une image de l'écran (JPEG) pendant un partage. */
|
||||||
|
fun screen(jpeg: ByteArray) {
|
||||||
|
request("POST", "$apiBase/api/device/screen", token, jpeg, "image/jpeg")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signale au serveur qu'une commande a été exécutée (audit). */
|
||||||
|
fun commandAck(id: String, status: String, detail: String) {
|
||||||
|
request("POST", "$apiBase/api/device/command-ack", token,
|
||||||
|
JSONObject().put("id", id).put("status", status).put("detail", detail.take(300)))
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val CONNECT_TIMEOUT_MS = 10_000
|
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||||
private const val READ_TIMEOUT_MS = 15_000
|
private const val READ_TIMEOUT_MS = 15_000
|
||||||
@@ -50,24 +66,31 @@ class FamilyApi(private val apiBase: String, private val token: String) {
|
|||||||
return request("POST", "${serverUrl.trimEnd('/')}/api/pair", null, body)
|
return request("POST", "${serverUrl.trimEnd('/')}/api/pair", null, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun request(method: String, url: String, token: String?, body: JSONObject?): String {
|
fun request(method: String, url: String, token: String?, body: JSONObject?, readTimeoutMs: Int = READ_TIMEOUT_MS): String {
|
||||||
|
return request(method, url, token, body?.toString()?.toByteArray(Charsets.UTF_8),
|
||||||
|
"application/json; charset=utf-8", readTimeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun request(method: String, url: String, token: String?, body: ByteArray?, contentType: String,
|
||||||
|
readTimeoutMs: Int = READ_TIMEOUT_MS): String {
|
||||||
val conn = try {
|
val conn = try {
|
||||||
(URL(url).openConnection() as HttpURLConnection).apply {
|
(URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
requestMethod = method
|
requestMethod = method
|
||||||
connectTimeout = CONNECT_TIMEOUT_MS
|
connectTimeout = CONNECT_TIMEOUT_MS
|
||||||
readTimeout = READ_TIMEOUT_MS
|
readTimeout = readTimeoutMs
|
||||||
setRequestProperty("Accept", "application/json")
|
setRequestProperty("Accept", "application/json")
|
||||||
token?.let { setRequestProperty("Authorization", "Bearer $it") }
|
token?.let { setRequestProperty("Authorization", "Bearer $it") }
|
||||||
if (body != null) {
|
if (body != null) {
|
||||||
doOutput = true
|
doOutput = true
|
||||||
setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
setRequestProperty("Content-Type", contentType)
|
||||||
|
setFixedLengthStreamingMode(body.size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
throw ApiException(0, "Serveur injoignable : ${e.message}")
|
throw ApiException(0, "Serveur injoignable : ${e.message}")
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (body != null) conn.outputStream.use { it.write(body.toString().toByteArray(Charsets.UTF_8)) }
|
if (body != null) conn.outputStream.use { it.write(body) }
|
||||||
val code = conn.responseCode
|
val code = conn.responseCode
|
||||||
val text = (if (code in 200..299) conn.inputStream else conn.errorStream)
|
val text = (if (code in 200..299) conn.inputStream else conn.errorStream)
|
||||||
?.bufferedReader()?.use { it.readText() }.orEmpty()
|
?.bufferedReader()?.use { it.readText() }.orEmpty()
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package com.jttools.datacontrol.remote
|
||||||
|
|
||||||
|
import android.app.admin.DevicePolicyManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.SystemClock
|
||||||
|
import com.jttools.datacontrol.admin.AdminController
|
||||||
|
import com.jttools.datacontrol.data.SettingsRepository
|
||||||
|
import com.jttools.datacontrol.log.AppLog
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.json.JSONObject
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions à distance demandées par le parent (via le serveur familyd) : verrouiller / mettre en veille,
|
||||||
|
* couper ou rétablir le son, partager l'écran. La boucle interroge le serveur en attente longue ; le
|
||||||
|
* partage d'écran est réalisé par le service d'accessibilité ([com.jttools.datacontrol.guard.AppGuardService]),
|
||||||
|
* qui observe [screenShareUntil].
|
||||||
|
*
|
||||||
|
* Le partage d'écran est toujours visible du téléphone (notification + bandeau « Écran partagé avec le parent »).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class RemoteControl @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val remote: RemoteSync,
|
||||||
|
private val settings: SettingsRepository,
|
||||||
|
private val ringer: Ringer
|
||||||
|
) {
|
||||||
|
/** Instant (temps monotone) jusqu'auquel l'écran est partagé ; 0 = pas de partage. */
|
||||||
|
private val _screenShareUntil = MutableStateFlow(0L)
|
||||||
|
val screenShareUntil: StateFlow<Long> = _screenShareUntil.asStateFlow()
|
||||||
|
|
||||||
|
val screenSharing: Boolean get() = _screenShareUntil.value > SystemClock.elapsedRealtime()
|
||||||
|
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
fun start(scope: CoroutineScope) {
|
||||||
|
if (started) return
|
||||||
|
started = true
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
while (isActive) {
|
||||||
|
if (!settings.settingsFlow.first().isPaired) {
|
||||||
|
delay(10_000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val commands = remote.pollCommands(WAIT_SECONDS)
|
||||||
|
if (commands == null) {
|
||||||
|
delay(5_000) // tunnel coupé / serveur injoignable : on réessaie posément
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (i in 0 until commands.length()) {
|
||||||
|
val cmd = commands.getJSONObject(i)
|
||||||
|
val outcome = runCatching { handle(cmd) }
|
||||||
|
val (status, detail) = outcome.getOrNull()
|
||||||
|
?: ("failed" to (outcome.exceptionOrNull()?.message ?: "erreur"))
|
||||||
|
remote.ackCommand(cmd.optString("id"), status, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fin automatique du partage d'écran à l'échéance.
|
||||||
|
scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
if (screenSharing) {
|
||||||
|
delay(1_000)
|
||||||
|
} else {
|
||||||
|
if (_screenShareUntil.value != 0L) _screenShareUntil.value = 0L
|
||||||
|
delay(2_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renvoie (statut, détail) pour l'audit : "done" ou "failed". */
|
||||||
|
private fun handle(cmd: JSONObject): Pair<String, String> = when (cmd.optString("type")) {
|
||||||
|
"lock" -> lock()
|
||||||
|
"mute" -> setMuted(true)
|
||||||
|
"unmute" -> setMuted(false)
|
||||||
|
"screen_start" -> {
|
||||||
|
val seconds = cmd.optInt("seconds", 120).coerceIn(10, 300)
|
||||||
|
_screenShareUntil.value = SystemClock.elapsedRealtime() + seconds * 1000L
|
||||||
|
AppLog.i("Commande", "Partage d'écran démarré ($seconds s)")
|
||||||
|
"done" to "partage démarré"
|
||||||
|
}
|
||||||
|
"screen_stop" -> {
|
||||||
|
_screenShareUntil.value = 0L
|
||||||
|
AppLog.i("Commande", "Partage d'écran arrêté")
|
||||||
|
"done" to ""
|
||||||
|
}
|
||||||
|
"ring" -> ringer.start(cmd.optInt("seconds", 60))
|
||||||
|
"ring_stop" -> {
|
||||||
|
ringer.stop(silent = false)
|
||||||
|
"done" to ""
|
||||||
|
}
|
||||||
|
else -> "failed" to "commande inconnue"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lock(): Pair<String, String> {
|
||||||
|
val dpm = context.getSystemService(DevicePolicyManager::class.java)
|
||||||
|
if (!AdminController.isActive(context)) {
|
||||||
|
AppLog.w("Commande", "Verrouillage impossible : DataControl n'est pas administrateur de l'appareil")
|
||||||
|
return "failed" to "DataControl n'est pas administrateur de l'appareil"
|
||||||
|
}
|
||||||
|
return runCatching { dpm.lockNow() }.fold(
|
||||||
|
onSuccess = { AppLog.i("Commande", "Téléphone verrouillé / mis en veille"); "done" to "" },
|
||||||
|
onFailure = { AppLog.w("Commande", "Verrouillage refusé : ${it.message}"); "failed" to (it.message ?: "refusé") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setMuted(muted: Boolean): Pair<String, String> {
|
||||||
|
val am = context.getSystemService(AudioManager::class.java)
|
||||||
|
val dir = if (muted) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE
|
||||||
|
val streams = intArrayOf(
|
||||||
|
AudioManager.STREAM_MUSIC, AudioManager.STREAM_RING,
|
||||||
|
AudioManager.STREAM_NOTIFICATION, AudioManager.STREAM_SYSTEM
|
||||||
|
)
|
||||||
|
var any = false
|
||||||
|
for (s in streams) {
|
||||||
|
// Le son de la sonnerie peut exiger l'accès « Ne pas déranger » : on n'échoue pas pour autant.
|
||||||
|
if (runCatching { am.adjustStreamVolume(s, dir, 0) }.isSuccess) any = true
|
||||||
|
}
|
||||||
|
AppLog.i("Commande", if (muted) "Son coupé (${if (any) "ok" else "refusé"})" else "Son rétabli")
|
||||||
|
return if (any) "done" to "" else "failed" to "aucun flux audio modifiable (accès « Ne pas déranger » ?)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val WAIT_SECONDS = 25
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -45,6 +46,27 @@ class RemoteSync @Inject constructor(
|
|||||||
return FamilyApi(link.apiBase, token)
|
return FamilyApi(link.apiBase, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Attend les commandes du parent (bloquant jusqu'à [waitSeconds] s). null si le tunnel est coupé ou en erreur. */
|
||||||
|
suspend fun pollCommands(waitSeconds: Int): JSONArray? = withContext(Dispatchers.IO) {
|
||||||
|
if (!tunnel.up.value) return@withContext null
|
||||||
|
val api = api() ?: return@withContext null
|
||||||
|
try { api.commands(waitSeconds) } catch (_: ApiException) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Envoie une image de l'écran pendant un partage. */
|
||||||
|
suspend fun pushScreen(jpeg: ByteArray): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
if (!tunnel.up.value) return@withContext false
|
||||||
|
val api = api() ?: return@withContext false
|
||||||
|
try { api.screen(jpeg); true } catch (_: ApiException) { false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accuse réception/exécution d'une commande (audit côté parent). */
|
||||||
|
suspend fun ackCommand(id: String, status: String, detail: String = "") = withContext(Dispatchers.IO) {
|
||||||
|
if (id.isBlank() || !tunnel.up.value) return@withContext
|
||||||
|
val api = api() ?: return@withContext
|
||||||
|
runCatching { api.commandAck(id, status, detail) }
|
||||||
|
}
|
||||||
|
|
||||||
/** Associe le téléphone au serveur : clés WireGuard générées ici, la clé privée ne quitte jamais l'appareil. */
|
/** Associe le téléphone au serveur : clés WireGuard générées ici, la clé privée ne quitte jamais l'appareil. */
|
||||||
suspend fun pair(input: PairingInput, deviceName: String): Result<FamilyLink> = withContext(Dispatchers.IO) {
|
suspend fun pair(input: PairingInput, deviceName: String): Result<FamilyLink> = withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -98,6 +120,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
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.media.AudioAttributes
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.media.MediaPlayer
|
||||||
|
import android.media.RingtoneManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
|
import android.os.VibratorManager
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.jttools.datacontrol.R
|
||||||
|
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 java.util.Timer
|
||||||
|
import java.util.TimerTask
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* « Faire sonner » demandé par le parent : joue une sonnerie d'alarme à plein volume, même si le
|
||||||
|
* téléphone est en silencieux (le flux d'alarme n'est pas concerné par le mode silencieux), vibre et
|
||||||
|
* affiche une notification « Appuyez pour arrêter ». S'arrête tout seul au bout de la durée demandée,
|
||||||
|
* à la demande du parent (commande ring_stop), ou quand l'enfant touche la notification.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class Ringer @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
private var player: MediaPlayer? = null
|
||||||
|
private var previousVolume = -1
|
||||||
|
private var stopTimer: Timer? = null
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun start(seconds: Int): Pair<String, String> {
|
||||||
|
stop(silent = true) // repart proprement si une sonnerie était déjà en cours
|
||||||
|
val am = context.getSystemService(AudioManager::class.java)
|
||||||
|
val uri = RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_ALARM)
|
||||||
|
?: RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_RINGTONE)
|
||||||
|
?: return "failed" to "aucune sonnerie disponible"
|
||||||
|
return try {
|
||||||
|
previousVolume = am.getStreamVolume(AudioManager.STREAM_ALARM)
|
||||||
|
am.setStreamVolume(AudioManager.STREAM_ALARM, am.getStreamMaxVolume(AudioManager.STREAM_ALARM), 0)
|
||||||
|
player = MediaPlayer().apply {
|
||||||
|
setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_ALARM)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
setDataSource(context, uri)
|
||||||
|
isLooping = true
|
||||||
|
prepare()
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
vibrate(true)
|
||||||
|
notify(context)
|
||||||
|
val secs = seconds.coerceIn(5, 300)
|
||||||
|
stopTimer = Timer().apply {
|
||||||
|
schedule(object : TimerTask() {
|
||||||
|
override fun run() = stop(silent = false)
|
||||||
|
}, secs * 1000L)
|
||||||
|
}
|
||||||
|
AppLog.i("Commande", "Sonnerie déclenchée ($secs s)")
|
||||||
|
"done" to "sonnerie déclenchée"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
AppLog.e("Commande", "Sonnerie impossible", e)
|
||||||
|
stop(silent = true)
|
||||||
|
"failed" to (e.message ?: "sonnerie impossible")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun stop(silent: Boolean) {
|
||||||
|
stopTimer?.cancel(); stopTimer = null
|
||||||
|
runCatching { player?.stop() }
|
||||||
|
runCatching { player?.release() }
|
||||||
|
player = null
|
||||||
|
vibrate(false)
|
||||||
|
if (previousVolume >= 0) {
|
||||||
|
runCatching {
|
||||||
|
context.getSystemService(AudioManager::class.java)
|
||||||
|
.setStreamVolume(AudioManager.STREAM_ALARM, previousVolume, 0)
|
||||||
|
}
|
||||||
|
previousVolume = -1
|
||||||
|
}
|
||||||
|
context.getSystemService(NotificationManager::class.java).cancel(NOTIF_ID)
|
||||||
|
if (!silent) AppLog.i("Commande", "Sonnerie arrêtée")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun vibrate(on: Boolean) {
|
||||||
|
val vib = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
context.getSystemService(VibratorManager::class.java)?.defaultVibrator
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION") context.getSystemService(Vibrator::class.java)
|
||||||
|
} ?: return
|
||||||
|
if (!on) { runCatching { vib.cancel() }; return }
|
||||||
|
runCatching {
|
||||||
|
val pattern = longArrayOf(0, 600, 400)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
vib.vibrate(VibrationEffect.createWaveform(pattern, 0))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION") vib.vibrate(pattern, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notify(context: Context) {
|
||||||
|
val nm = context.getSystemService(NotificationManager::class.java)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL, "Sonnerie", NotificationManager.IMPORTANCE_HIGH).apply {
|
||||||
|
setSound(null, null) // le son est joué par le lecteur (flux d'alarme)
|
||||||
|
enableVibration(false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val stop = PendingIntent.getBroadcast(
|
||||||
|
context, 0, Intent(context, RingStopReceiver::class.java),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
nm.notify(NOTIF_ID, NotificationCompat.Builder(context, CHANNEL)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle("DataControl sonne")
|
||||||
|
.setContentText("Demandé par le parent — touchez pour arrêter.")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(stop)
|
||||||
|
.setFullScreenIntent(stop, true)
|
||||||
|
.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL = "datacontrol_ring"
|
||||||
|
private const val NOTIF_ID = 3102
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arrêt de la sonnerie depuis la notification (touche de l'enfant). */
|
||||||
|
class RingStopReceiver : BroadcastReceiver() {
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface Deps {
|
||||||
|
fun ringer(): Ringer
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
EntryPointAccessors.fromApplication(context.applicationContext, Deps::class.java).ringer().stop(silent = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class DataMonitorService : LifecycleService() {
|
|||||||
@Inject lateinit var remoteSync: RemoteSync
|
@Inject lateinit var remoteSync: RemoteSync
|
||||||
@Inject lateinit var guardEngine: GuardEngine
|
@Inject lateinit var guardEngine: GuardEngine
|
||||||
@Inject lateinit var tunnel: TunnelManager
|
@Inject lateinit var tunnel: TunnelManager
|
||||||
|
@Inject lateinit var remoteControl: com.jttools.datacontrol.remote.RemoteControl
|
||||||
|
|
||||||
private var latestSettings: AppSettings = AppSettings()
|
private var latestSettings: AppSettings = AppSettings()
|
||||||
private var latestTransport: NetworkTransport = NetworkTransport.NONE
|
private var latestTransport: NetworkTransport = NetworkTransport.NONE
|
||||||
@@ -106,6 +107,7 @@ class DataMonitorService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
guardEngine.start()
|
guardEngine.start()
|
||||||
deviceReporter.start(lifecycleScope)
|
deviceReporter.start(lifecycleScope)
|
||||||
|
remoteControl.start(lifecycleScope)
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
connectivityObserver.observe().collect {
|
connectivityObserver.observe().collect {
|
||||||
latestTransport = it
|
latestTransport = it
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ 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("Actions à distance du parent", "Depuis l'espace parents : verrouiller / mettre en veille, couper ou rétablir le son, et voir l'écran en direct. Pendant un partage d'écran, un bandeau rouge « Écran partagé avec le parent » reste affiché sur le téléphone et le partage s'arrête tout seul au bout de 5 minutes."),
|
||||||
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.")
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
@@ -108,7 +110,8 @@ object AboutContent {
|
|||||||
)),
|
)),
|
||||||
AboutSection("Ce que voit le serveur", listOf(
|
AboutSection("Ce que voit le serveur", listOf(
|
||||||
AboutEntry("Noms de domaine et volume", "Comme tout le trafic passe par le serveur du parent, celui-ci voit les sites demandés (journal DNS conservé 7 jours) et le volume échangé."),
|
AboutEntry("Noms de domaine et volume", "Comme tout le trafic passe par le serveur du parent, celui-ci voit les sites demandés (journal DNS conservé 7 jours) et le volume échangé."),
|
||||||
AboutEntry("Pas le contenu", "Les échanges chiffrés des applications (HTTPS) restent illisibles pour le serveur ; le service d'accessibilité ne lit pas le contenu des écrans.")
|
AboutEntry("Pas le contenu", "Les échanges chiffrés des applications (HTTPS) restent illisibles pour le serveur ; le service d'accessibilité ne lit pas le contenu des écrans."),
|
||||||
|
AboutEntry("Partage d'écran visible", "Le parent ne peut voir l'écran que pendant un partage qu'il déclenche ; le téléphone l'affiche alors en permanence (bandeau rouge) et le partage s'arrête seul au bout de 5 minutes. Aucune capture en dehors de ces moments.")
|
||||||
)),
|
)),
|
||||||
AboutSection("Privilèges et licence", listOf(
|
AboutSection("Privilèges et licence", listOf(
|
||||||
AboutEntry("Rien d'activé à votre insu", "VPN, position, administrateur d'appareil et accessibilité passent chacun par une autorisation explicite d'Android."),
|
AboutEntry("Rien d'activé à votre insu", "VPN, position, administrateur d'appareil et accessibilité passent chacun par une autorisation explicite d'Android."),
|
||||||
|
|||||||
@@ -135,4 +135,5 @@
|
|||||||
<string name="family_owner_release">Retirer le mode propriétaire</string>
|
<string name="family_owner_release">Retirer le mode propriétaire</string>
|
||||||
<string name="family_owner_release_error">Retrait impossible : voir le journal.</string>
|
<string name="family_owner_release_error">Retrait impossible : voir le journal.</string>
|
||||||
<string name="family_owner_off">Inactif. Pour l\'activer (une seule fois, téléphone branché au PC, tous les comptes retirés le temps de la commande) :</string>
|
<string name="family_owner_off">Inactif. Pour l\'activer (une seule fois, téléphone branché au PC, tous les comptes retirés le temps de la commande) :</string>
|
||||||
|
<string name="screen_shared_banner">🔴 Écran partagé avec le parent</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -4,4 +4,5 @@
|
|||||||
android:accessibilityFeedbackType="feedbackGeneric"
|
android:accessibilityFeedbackType="feedbackGeneric"
|
||||||
android:canRetrieveWindowContent="false"
|
android:canRetrieveWindowContent="false"
|
||||||
android:description="@string/guard_service_description"
|
android:description="@string/guard_service_description"
|
||||||
android:notificationTimeout="100" />
|
android:notificationTimeout="100"
|
||||||
|
android:canTakeScreenshot="true" />
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<device-admin>
|
<device-admin>
|
||||||
<uses-policies />
|
<uses-policies>
|
||||||
|
<force-lock />
|
||||||
|
</uses-policies>
|
||||||
</device-admin>
|
</device-admin>
|
||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
versionName=0.3.0
|
versionName=0.7.0
|
||||||
versionCode=13
|
versionCode=17
|
||||||
Reference in new issue
Block a user