2 Commits
Author SHA1 Message Date
tuxgyverandClaude Opus 4.8 d4aa0e1962 feat: partage d'écran Android 5+ (MediaProjection) et blocage des installations
- Partage d'écran par MediaProjection (ScreenCaptureService + ScreenConsentActivity)
  quand la capture par accessibilité n'est pas possible (Android < 11 ou service coupé).
  En mode propriétaire, le service d'accessibilité valide seul le consentement système.
- Blocage de l'installation d'applications (Play Store compris) piloté par le parent
  (policy block_installs → OwnerPolicy DISALLOW_INSTALL_APPS en mode propriétaire).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
2026-10-25 22:28:16 +01:00
tuxgyverandClaude Opus 4.8 665d6e2cb3 feat: quatre modes réseau distincts (liste noire explicite)
Le serveur distingue désormais open / blocklist / allowlist / cut. La liste
noire (services bloqués par le DNS) devient un mode à part entière et ne
s'applique plus que dans ce mode. DataControl affiche le libellé du nouveau
mode sur le tableau de bord, la notification et le journal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
2026-10-25 21:50:36 +01:00
16 changed files with 338 additions and 18 deletions

No files matched your search

+9
View File
@@ -1,5 +1,14 @@
# Changelog
## v0.10.0 – 2026-10-25
- ✨ **Ajouté** — **Partage d'écran compatible dès Android 5** : capture par MediaProjection quand la capture par le service d'accessibilité n'est pas possible (celle-ci reste réservée à Android 11+). En mode propriétaire, le consentement système « Démarrer la capture » peut être validé automatiquement par le service d'accessibilité ; sinon un appui est demandé sur le téléphone. Une notification « Écran partagé avec le parent » reste affichée pendant le partage.
- ✨ **Ajouté** — **Blocage de l'installation d'applications** (Play Store compris), piloté par le parent depuis l'espace parents. Appliqué en mode propriétaire (restriction système `DISALLOW_INSTALL_APPS`).
## v0.9.0 – 2026-10-25
- 🛠️ **Modifié** — **Quatre modes réseau distincts** décidés par le serveur, affichés sur le tableau de bord et la notification : *Accès libre* (aucun filtrage), *Liste noire* (tout sauf les services bloqués), *Liste blanche* (rien sauf les domaines autorisés), *Internet coupé*. La liste noire ne s'applique désormais que dans le mode qui lui correspond.
## v0.8.0 – 2026-10-25
- ✨ **Ajouté** — **Verrouillage parental à distance** : depuis l'espace parents, le parent verrouille le téléphone sur un écran de blocage plein écran que seul un **code à usage unique** (généré par le serveur et dicté à l'enfant) permet de lever. En mode propriétaire, l'écran est épinglé (accueil et récents neutralisés) ; sinon il revient de lui-même si l'enfant tente de le quitter. Le parent peut aussi déverrouiller à distance.
+1 -1
View File
@@ -1,6 +1,6 @@
# DataControl
🚀 v0.8.0 · 🤖 Android · 🟣 Kotlin · 🎨 Jetpack Compose · 🔒 WireGuard · 🛡️ mode propriétaire · 👨‍👩‍👧 contrôle parental · 🧪 tests · 📄 licence non déclarée
🚀 v0.10.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.
+13
View File
@@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<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_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -46,6 +47,18 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Partage d'écran par MediaProjection (fonctionne dès Android 5, contrairement à l'accessibilité). -->
<service
android:name=".remote.ScreenCaptureService"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<activity
android:name=".remote.ScreenConsentActivity"
android:exported="false"
android:excludeFromRecents="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- FileProvider pour installer l'APK de mise à jour téléchargé -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -26,8 +26,9 @@ object OwnerPolicy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) add(UserManager.DISALLOW_CONFIG_PRIVATE_DNS)
}
/** État voulu, appliqué tel quel ; [lockdown] = connexions bloquées hors VPN. */
data class Target(val protect: Boolean, val lockdown: Boolean)
/** État voulu, appliqué tel quel ; [lockdown] = connexions bloquées hors VPN ;
* [blockInstalls] = installation d'applications interdite (Play Store compris). */
data class Target(val protect: Boolean, val lockdown: Boolean, val blockInstalls: Boolean = false)
private var applied: Target? = null
@@ -49,11 +50,15 @@ object OwnerPolicy {
// Le système accorde lui-même l'autorisation VPN au paquet désigné : aucune fenêtre de consentement.
dpm.setAlwaysOnVpnPackage(admin, context.packageName, target.lockdown)
RESTRICTIONS.forEach { dpm.addUserRestriction(admin, it) }
// Blocage des installations : facultatif, piloté par le parent.
if (target.blockInstalls) dpm.addUserRestriction(admin, UserManager.DISALLOW_INSTALL_APPS)
else dpm.clearUserRestriction(admin, UserManager.DISALLOW_INSTALL_APPS)
dpm.setUninstallBlocked(admin, context.packageName, true)
requireAutoTime(context)
} else {
dpm.setAlwaysOnVpnPackage(admin, null, false)
RESTRICTIONS.forEach { dpm.clearUserRestriction(admin, it) }
dpm.clearUserRestriction(admin, UserManager.DISALLOW_INSTALL_APPS)
dpm.setUninstallBlocked(admin, context.packageName, false)
}
applied = target
@@ -25,6 +25,8 @@ data class AppSettings(
val serverMode: String = "open",
val serverModeReason: String = "",
val policyVersion: Long = 0L,
/** Bloque l'installation d'applications (Play Store compris) — appliqué en mode propriétaire. */
val blockInstalls: Boolean = false,
/** Téléphone verrouillé à distance par le parent : un code du serveur est exigé pour le déverrouiller. */
val parentalLocked: Boolean = false
) {
@@ -58,6 +58,7 @@ class SettingsRepository @Inject constructor(
val LINK_TOKEN_ENC = stringPreferencesKey("link_token_enc")
val WG_PRIVATE_ENC = stringPreferencesKey("wg_private_enc")
val SERVER_MODE = stringPreferencesKey("server_mode")
val BLOCK_INSTALLS = booleanPreferencesKey("block_installs")
val SERVER_MODE_REASON = stringPreferencesKey("server_mode_reason")
val POLICY_VERSION = longPreferencesKey("policy_version")
val EVENT_QUEUE = stringPreferencesKey("event_queue")
@@ -103,7 +104,8 @@ class SettingsRepository @Inject constructor(
serverMode = prefs[Keys.SERVER_MODE] ?: defaults.serverMode,
serverModeReason = prefs[Keys.SERVER_MODE_REASON] ?: defaults.serverModeReason,
policyVersion = prefs[Keys.POLICY_VERSION] ?: defaults.policyVersion,
parentalLocked = prefs[Keys.PARENTAL_LOCKED] ?: defaults.parentalLocked
parentalLocked = prefs[Keys.PARENTAL_LOCKED] ?: defaults.parentalLocked,
blockInstalls = prefs[Keys.BLOCK_INSTALLS] ?: defaults.blockInstalls
)
}
@@ -324,11 +326,12 @@ class SettingsRepository @Inject constructor(
suspend fun wgPrivateKey(): String? = SecretBox.decrypt(context.dataStore.data.first()[Keys.WG_PRIVATE_ENC])
/** Consigne reçue du serveur (mode réseau en cours et règles d'applications). */
suspend fun applyServerPolicy(version: Long, mode: String, reason: String, rulesJson: String?) {
suspend fun applyServerPolicy(version: Long, mode: String, reason: String, rulesJson: String?, blockInstalls: Boolean) {
context.dataStore.edit {
it[Keys.POLICY_VERSION] = version
it[Keys.SERVER_MODE] = mode
it[Keys.SERVER_MODE_REASON] = reason
it[Keys.BLOCK_INSTALLS] = blockInstalls
if (rulesJson.isNullOrBlank() || rulesJson == "{}") it.remove(Keys.RULES_JSON) else it[Keys.RULES_JSON] = rulesJson
}
}
@@ -18,6 +18,7 @@ import android.os.PowerManager
import android.provider.Settings
import android.telecom.TelecomManager
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import android.view.inputmethod.InputMethodManager
import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.log.AppLog
@@ -186,6 +187,11 @@ class AppGuardService : AccessibilityService() {
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event?.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return
val pkg = event.packageName?.toString() ?: return
// Auto-validation du consentement au partage d'écran (dialogue système), en mode propriétaire.
if (remoteControl.awaitingScreenConsent && confirmProjectionConsent()) {
remoteControl.awaitingScreenConsent = false
return
}
// Verrouillage parental : toute tentative de passer à une autre application ramène l'écran de blocage.
if (parentalLocked && pkg != packageName) {
enforceParentalLock()
@@ -216,6 +222,23 @@ class AppGuardService : AccessibilityService() {
runCatching { startActivity(ParentalLockActivity.intent(this)) }
}
/** Clique « Démarrer maintenant » sur le dialogue système de partage d'écran (MediaProjection). */
private fun confirmProjectionConsent(): Boolean {
val root = rootInActiveWindow ?: return false
val btn = findClickableByText(root, CONSENT_WORDS)
return btn != null && btn.performAction(AccessibilityNodeInfo.ACTION_CLICK)
}
private fun findClickableByText(node: AccessibilityNodeInfo?, re: Regex): AccessibilityNodeInfo? {
if (node == null) return null
val text = (node.text?.toString() ?: "") + " " + (node.contentDescription?.toString() ?: "")
if (node.isClickable && re.containsMatchIn(text)) return node
for (i in 0 until node.childCount) {
findClickableByText(node.getChild(i), re)?.let { return it }
}
return null
}
private fun enforce(pkg: String, block: Block) {
val now = System.currentTimeMillis()
if (now - lastBlockAt < 2_000) return
@@ -248,6 +271,7 @@ class AppGuardService : AccessibilityService() {
return when (snap.serverMode) {
"cut" -> "BLOQUÉ (Internet coupé par le serveur) [$net]" to true
"allowlist" -> "LIMITÉ (liste blanche du serveur) [$net]" to true
"blocklist" -> "filtré (liste noire du serveur) [$net]" to false
else -> "filtré par le serveur (accès libre) [$net]" to false
}
}
@@ -285,6 +309,7 @@ class AppGuardService : AccessibilityService() {
}
companion object {
private val CONSENT_WORDS = Regex("démarrer|commencer|autoriser|accepter|start now|\\ballow\\b", RegexOption.IGNORE_CASE)
private const val CHECK_INTERVAL_MS = 10_000L
private const val EXEMPT_TTL_MS = 5 * 60_000L
private const val SCREEN_INTERVAL_MS = 1_500L
@@ -3,8 +3,10 @@ package com.jttools.datacontrol.remote
import android.app.admin.DevicePolicyManager
import android.content.Context
import android.media.AudioManager
import android.os.Build
import android.os.SystemClock
import com.jttools.datacontrol.admin.AdminController
import com.jttools.datacontrol.guard.AppGuardService
import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.guard.ParentalLockActivity
import com.jttools.datacontrol.log.AppLog
@@ -43,6 +45,9 @@ class RemoteControl @Inject constructor(
val screenSharing: Boolean get() = _screenShareUntil.value > SystemClock.elapsedRealtime()
/** Vrai pendant qu'on attend le consentement MediaProjection (le dialogue système est à l'écran). */
@Volatile var awaitingScreenConsent = false
private var started = false
fun start(scope: CoroutineScope) {
@@ -88,14 +93,11 @@ class RemoteControl @Inject constructor(
"punlock" -> parentalLock(false)
"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_start" -> startScreenShare(cmd.optInt("seconds", 120).coerceIn(10, 300))
"screen_stop" -> {
_screenShareUntil.value = 0L
awaitingScreenConsent = false
ScreenCaptureService.stop(context)
AppLog.i("Commande", "Partage d'écran arrêté")
"done" to ""
}
@@ -123,6 +125,22 @@ class RemoteControl @Inject constructor(
}
}
/** Démarre le partage d'écran : capture silencieuse par l'accessibilité (Android 11+) ou MediaProjection. */
private fun startScreenShare(seconds: Int): Pair<String, String> {
_screenShareUntil.value = SystemClock.elapsedRealtime() + seconds * 1000L
val silent = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && AppGuardService.isEnabled(context)
if (silent) {
awaitingScreenConsent = false
AppLog.i("Commande", "Partage d'écran démarré ($seconds s, accessibilité)")
} else if (!ScreenCaptureService.running) {
awaitingScreenConsent = true
runCatching { context.startActivity(ScreenConsentActivity.intent(context)) }
.onFailure { AppLog.w("Commande", "Lancement du partage MediaProjection impossible : ${it.message}") }
AppLog.i("Commande", "Partage d'écran démarré ($seconds s, MediaProjection)")
}
return "done" to "partage démarré"
}
private fun lock(): Pair<String, String> {
val dpm = context.getSystemService(DevicePolicyManager::class.java)
if (!AdminController.isActive(context)) {
@@ -136,10 +136,13 @@ class RemoteSync @Inject constructor(
val version = p.optLong("version")
val mode = p.optString("mode", "open")
val reason = p.optString("mode_reason")
if (version != current.policyVersion || mode != current.serverMode || reason != current.serverModeReason) {
val blockInstalls = p.optBoolean("block_installs", false)
if (version != current.policyVersion || mode != current.serverMode || reason != current.serverModeReason
|| blockInstalls != current.blockInstalls) {
val rules = p.optJSONObject("app_rules")?.takeIf { it.length() > 0 }?.toString()
settings.applyServerPolicy(version, mode, reason, rules)
settings.applyServerPolicy(version, mode, reason, rules, blockInstalls)
if (mode != current.serverMode) AppLog.i("Liaison", "Mode réseau : $mode${if (reason.isNotBlank()) " ($reason)" else ""}")
if (blockInstalls != current.blockInstalls) AppLog.i("Liaison", "Blocage des installations : $blockInstalls")
}
// Mise à jour de DataControl poussée par le parent.
p.optJSONObject("deploy")?.let { order ->
@@ -0,0 +1,193 @@
package com.jttools.datacontrol.remote
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.ImageReader
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.IBinder
import android.os.SystemClock
import android.util.DisplayMetrics
import android.view.WindowManager
import androidx.core.app.NotificationCompat
import com.jttools.datacontrol.R
import com.jttools.datacontrol.log.AppLog
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream
import javax.inject.Inject
/**
* Partage d'écran par MediaProjection — fonctionne dès Android 5, contrairement à la capture par le
* service d'accessibilité ([com.jttools.datacontrol.guard.AppGuardService]) réservée à Android 11+.
*
* Démarré par [ScreenConsentActivity] après le consentement du système (ou son auto-validation par
* l'accessibilité en mode propriétaire). Capture l'écran tant que le parent partage
* ([RemoteControl.screenSharing]), redimensionne et envoie les images au serveur par le tunnel.
*/
@AndroidEntryPoint
class ScreenCaptureService : Service() {
@Inject lateinit var remoteSync: RemoteSync
@Inject lateinit var remoteControl: RemoteControl
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var projection: MediaProjection? = null
private var virtualDisplay: VirtualDisplay? = null
private var imageReader: ImageReader? = null
private var thread: HandlerThread? = null
private var lastSent = 0L
@Volatile private var busy = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) { stop(); return START_NOT_STICKY }
startForegroundNotif()
val code = intent?.getIntExtra(EXTRA_CODE, 0) ?: 0
val data: Intent? = intent?.let {
if (Build.VERSION.SDK_INT >= 33) it.getParcelableExtra(EXTRA_DATA, Intent::class.java)
else @Suppress("DEPRECATION") it.getParcelableExtra(EXTRA_DATA)
}
if (data == null || code == 0) { stop(); return START_NOT_STICKY }
running = true
val mpm = getSystemService(MediaProjectionManager::class.java)
projection = try {
mpm.getMediaProjection(code, data)
} catch (e: Exception) {
AppLog.e("Écran", "MediaProjection impossible", e); stop(); return START_NOT_STICKY
}
projection?.registerCallback(object : MediaProjection.Callback() {
override fun onStop() { stop() }
}, Handler(mainLooper))
startCapture()
scope.launch { // s'arrête dès que le parent cesse de partager
while (running) {
if (!remoteControl.screenSharing) { stop(); break }
delay(1000)
}
}
return START_NOT_STICKY
}
private fun startCapture() {
val metrics = DisplayMetrics()
@Suppress("DEPRECATION")
getSystemService(WindowManager::class.java).defaultDisplay.getRealMetrics(metrics)
val scale = if (metrics.widthPixels > MAX_WIDTH) MAX_WIDTH.toFloat() / metrics.widthPixels else 1f
val w = (metrics.widthPixels * scale).toInt().coerceAtLeast(1)
val h = (metrics.heightPixels * scale).toInt().coerceAtLeast(1)
thread = HandlerThread("screencap").apply { start() }
val handler = Handler(thread!!.looper)
val reader = ImageReader.newInstance(w, h, PixelFormat.RGBA_8888, 2)
imageReader = reader
virtualDisplay = projection?.createVirtualDisplay(
"datacontrol-screen", w, h, metrics.densityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, reader.surface, null, handler
)
reader.setOnImageAvailableListener({ r ->
val image = r.acquireLatestImage() ?: return@setOnImageAvailableListener
val now = SystemClock.elapsedRealtime()
if (busy || now - lastSent < CAPTURE_INTERVAL_MS) { image.close(); return@setOnImageAvailableListener }
lastSent = now
busy = true
try {
val plane = image.planes[0]
val pixelStride = plane.pixelStride
val rowPadding = plane.rowStride - pixelStride * w
val bmp = Bitmap.createBitmap(w + rowPadding / pixelStride, h, Bitmap.Config.ARGB_8888)
bmp.copyPixelsFromBuffer(plane.buffer)
image.close()
val frame = if (rowPadding == 0) bmp else Bitmap.createBitmap(bmp, 0, 0, w, h).also { bmp.recycle() }
scope.launch {
val jpeg = ByteArrayOutputStream().use { out ->
frame.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out); out.toByteArray()
}
frame.recycle()
runCatching { remoteSync.pushScreen(jpeg) }
busy = false
}
} catch (e: Exception) {
runCatching { image.close() }
busy = false
AppLog.w("Écran", "Capture impossible : ${e.message}")
}
}, handler)
AppLog.i("Écran", "Partage d'écran (MediaProjection) démarré — $w×$h")
}
private fun stop() {
running = false
runCatching { virtualDisplay?.release() }
runCatching { imageReader?.close() }
runCatching { projection?.stop() }
virtualDisplay = null; imageReader = null; projection = null
thread?.quitSafely(); thread = null
scope.cancel()
runCatching { stopForeground(STOP_FOREGROUND_REMOVE) }
stopSelf()
}
override fun onDestroy() { running = false; super.onDestroy() }
private fun startForegroundNotif() {
val nm = getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
nm.createNotificationChannel(
NotificationChannel(CHANNEL, "Partage d'écran", NotificationManager.IMPORTANCE_LOW)
)
}
val notif = NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Écran partagé avec le parent")
.setContentText("DataControl partage l'écran de ce téléphone.")
.setOngoing(true)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
} else {
startForeground(NOTIF_ID, notif)
}
}
companion object {
@Volatile var running = false
private set
private const val CHANNEL = "screen_share"
private const val NOTIF_ID = 4242
private const val EXTRA_CODE = "code"
private const val EXTRA_DATA = "data"
private const val ACTION_STOP = "stop"
private const val MAX_WIDTH = 720
private const val JPEG_QUALITY = 50
private const val CAPTURE_INTERVAL_MS = 1200L
fun start(context: Context, resultCode: Int, data: Intent) {
val i = Intent(context, ScreenCaptureService::class.java)
.putExtra(EXTRA_CODE, resultCode).putExtra(EXTRA_DATA, data)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(i)
else context.startService(i)
}
fun stop(context: Context) {
if (!running) return
context.startService(Intent(context, ScreenCaptureService::class.java).setAction(ACTION_STOP))
}
}
}
@@ -0,0 +1,43 @@
package com.jttools.datacontrol.remote
import android.content.Context
import android.content.Intent
import android.media.projection.MediaProjectionManager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import com.jttools.datacontrol.log.AppLog
/**
* Demande le consentement du système au partage d'écran (MediaProjection) puis lance
* [ScreenCaptureService]. Transparente et sans interface : en mode propriétaire, le service
* d'accessibilité peut valider seul le dialogue « Démarrer la capture » (sinon un appui est requis).
*/
class ScreenConsentActivity : ComponentActivity() {
private val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { res ->
val data = res.data
if (res.resultCode == RESULT_OK && data != null) {
ScreenCaptureService.start(this, res.resultCode, data)
} else {
AppLog.w("Écran", "Partage d'écran refusé sur le téléphone")
}
finish()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val mpm = getSystemService(MediaProjectionManager::class.java)
try {
launcher.launch(mpm.createScreenCaptureIntent())
} catch (e: Exception) {
AppLog.e("Écran", "Demande de partage d'écran impossible", e)
finish()
}
}
companion object {
fun intent(context: Context): Intent =
Intent(context, ScreenConsentActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
@@ -166,7 +166,8 @@ class DataMonitorService : LifecycleService() {
val dataState = when {
!settings.controlEnabled -> if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
settings.serverMode != "open" -> DataState.CUT
// « blocklist » laisse tout passer (sauf la liste noire DNS) : traité comme « open », pas comme coupé.
settings.serverMode == "allowlist" || settings.serverMode == "cut" -> DataState.CUT
latestTransport == NetworkTransport.WIFI -> DataState.WIFI
percent >= WARNING_THRESHOLD -> DataState.WARNING
else -> DataState.OK
@@ -243,7 +244,8 @@ class DataMonitorService : LifecycleService() {
else remoteSync.pushEvent("lockdown_restored", "Serveur de nouveau joignable : blocage hors VPN rétabli")
}
}
OwnerPolicy.sync(this, OwnerPolicy.Target(protect = wanted, lockdown = wanted && !serverLost))
OwnerPolicy.sync(this, OwnerPolicy.Target(protect = wanted, lockdown = wanted && !serverLost,
blockInstalls = latestSettings.blockInstalls))
}
/** Monte le tunnel, au plus une tentative toutes les [TUNNEL_RETRY_MS] (évite de boucler contre un autre VPN). */
@@ -295,6 +297,7 @@ class DataMonitorService : LifecycleService() {
val modeLabel = when (snapshot.serverMode) {
"cut" -> getString(R.string.mode_cut)
"allowlist" -> getString(R.string.mode_allowlist)
"blocklist" -> getString(R.string.mode_blocklist)
else -> getString(R.string.mode_open)
}
val (emoji, stateLabel, color) = when {
@@ -33,7 +33,8 @@ object AboutContent {
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("Actions à distance du parent", "Depuis l'espace parents : verrouiller / mettre en veille, couper ou rétablir le son, faire sonner, voir l'écran en direct, et verrouiller le téléphone sur un écran de blocage que seul un code du serveur lève. 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("Actions à distance du parent", "Depuis l'espace parents : verrouiller / mettre en veille, couper ou rétablir le son, faire sonner, voir l'écran en direct, et verrouiller le téléphone sur un écran de blocage que seul un code du serveur lève. Le partage d'écran fonctionne dès Android 5 (MediaProjection) ou, sur Android 11+, par le service d'accessibilité ; une notification « Écran partagé avec le parent » reste affichée et le partage s'arrête tout seul au bout de 5 minutes."),
AboutEntry("Blocage des installations", "Le parent peut interdire l'installation de nouvelles applications (Play Store compris) ; appliqué en mode propriétaire de l'appareil."),
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.")
))
)
@@ -211,6 +211,7 @@ fun DashboardScreen(systemActions: SystemActions) {
val modeText = when (snapshot.serverMode) {
"cut" -> stringResource(R.string.mode_cut)
"allowlist" -> stringResource(R.string.mode_allowlist)
"blocklist" -> stringResource(R.string.mode_blocklist)
else -> stringResource(R.string.mode_open)
}
val tunnelText = when {
+1
View File
@@ -127,6 +127,7 @@
<string name="family_guard_enable">Activer le service</string>
<!-- v0.2 : tunnel WireGuard et serveur familyd -->
<string name="mode_open">Accès libre</string>
<string name="mode_blocklist">Liste noire (certains services bloqués)</string>
<string name="mode_allowlist">Liste blanche seulement</string>
<string name="mode_cut">Internet coupé</string>
<string name="dashboard_tunnel">Tunnel</string>
+2 -2
View File
@@ -1,2 +1,2 @@
versionName=0.8.0
versionCode=18
versionName=0.10.0
versionCode=20