Phase 5.1: kids' Android client (Accessibility block overlay + tamper alert)

Add android/, a normal Gradle/Kotlin project with its own wrapper. It
blocks by interruption: an Accessibility Service watches the foreground
package and a full-screen overlay covers any listed app while the device
is locked (FR-16). No Device Owner, no factory reset, no managed profile.

The client subscribes to the retained restrict/<device>/config topic on
the 8883 TLS plane, keeps the last config on disk so a broker-less device
keeps its rules (FR-14), and reports tampering on its own status topic
(FR-17): a disabled service, a revoked overlay permission, or a
force-stop seen on the next start. The Control Service now parses the
tamper field and raises an Apprise alert, so it reaches Sam.

The block path and the tamper alert are proven on the headless pixel35
emulator on .27; see android/README.md and android/tools/emu-proof.sh.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-13 20:17:22 +11:00
parent 8c42daf4d9
commit e018c3bfa8
39 changed files with 2323 additions and 7 deletions

View File

@@ -0,0 +1,71 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "au.audasmedia.webrestrict.kids"
compileSdk = 35
defaultConfig {
applicationId = "au.audasmedia.webrestrict.kids"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0-phase5.1"
// The emulator default: a device config with the Settings app blocked.
// A real device receives the retained config over MQTT and overwrites
// this, but a broker-less device must still block (FR-14).
buildConfigField("String", "DEFAULT_DEVICE_TOPIC", "\"restrict/harry_phone_1/config\"")
buildConfigField("String", "DEFAULT_DEVICE_NAME", "\"Harry's phone\"")
buildConfigField("String", "DEFAULT_BROKER_HOST", "\"192.168.20.13\"")
buildConfigField("int", "DEFAULT_BROKER_PORT", "8883")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
debug {
// The headless emulator proof installs the debug build. Keep the
// application id identical to release so the adb commands that
// enable the Accessibility Service stay simple.
versionNameSuffix = "-debug"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
buildConfig = true
viewBinding = false
}
packaging {
resources.excludes += setOf("META-INF/*.kotlin_module")
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.appcompat:appcompat:1.7.0")
// MQTT over TLS on the 8883 plane (AGENTS.md §10 rule 6). The Java client is
// pure JVM, so it also runs under the unit tests.
implementation("org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5")
// JSON is identical on Android and on the JVM, so Config parsing is unit
// tested without Robolectric.
implementation("com.google.code.gson:gson:2.11.0")
testImplementation("junit:junit:4.13.2")
}

3
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,3 @@
# Keep the release build the default rules only; nothing here needs removal.
-keep class org.eclipse.paho.client.mqttv3.** { *; }
-dontwarn org.eclipse.paho.client.mqttv3.**

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Kids' Android client (AGENTS.md §11 Phase 5, FR-16 + FR-17).
No Device Owner, no factory reset, no managed profile. The app is an ordinary
app that asks for the Accessibility Service and the overlay permission. The
child can disable it; the enforcement is the tamper alert, not the overlay.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- The full-screen block overlay is a TYPE_APPLICATION_OVERLAY window. -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- The watchdog keeps a config connection alive and reports tampering. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name=".KidsApp"
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.WebRestrict"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- FR-16: the foreground-app watcher. Disabling it raises a tamper
alert (FR-17). It never changes its own rules; it obeys the config
the Control Service publishes. -->
<service
android:name=".BlockAccessibilityService"
android:exported="true"
android:label="@string/accessibility_label"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />
</service>
<!-- FR-17: after a reboot, report that the device is back and reconnect
to the broker so the retained config is fetched at once. -->
<receiver
android:name=".BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -0,0 +1,154 @@
package au.audasmedia.webrestrict.kids
import android.content.Context
import android.content.SharedPreferences
import android.provider.Settings
import android.util.Log
/**
* Small, synchronous preferences store. Everything here is local state, never a
* second source of truth: the Control Service still owns the rules.
*/
class AppPrefs(context: Context) {
private val prefs: SharedPreferences =
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
var deviceTopic: String
get() = prefs.getString(KEY_TOPIC, BuildConfig.DEFAULT_DEVICE_TOPIC)!!
set(value) = prefs.edit().putString(KEY_TOPIC, value).apply()
var deviceName: String
get() = prefs.getString(KEY_DEVICE_NAME, BuildConfig.DEFAULT_DEVICE_NAME)!!
set(value) = prefs.edit().putString(KEY_DEVICE_NAME, value).apply()
var brokerHost: String
get() = prefs.getString(KEY_BROKER_HOST, BuildConfig.DEFAULT_BROKER_HOST)!!
set(value) = prefs.edit().putString(KEY_BROKER_HOST, value).apply()
var brokerPort: Int
get() = prefs.getInt(KEY_BROKER_PORT, BuildConfig.DEFAULT_BROKER_PORT)
set(value) = prefs.edit().putInt(KEY_BROKER_PORT, value).apply()
/** The MQTT account. The broker ACL names it after the topic token. */
var mqttUsername: String
get() = prefs.getString(KEY_MQTT_USER, topicToken())!!
set(value) = prefs.edit().putString(KEY_MQTT_USER, value).apply()
var mqttPassword: String
get() = prefs.getString(KEY_MQTT_PASSWORD, "")!!
set(value) = prefs.edit().putString(KEY_MQTT_PASSWORD, value).apply()
/** Optional asset file holding the lab CA certificate, e.g. "lab-ca.pem". */
var caAsset: String
get() = prefs.getString(KEY_CA_ASSET, "")!!
set(value) = prefs.edit().putString(KEY_CA_ASSET, value).apply()
/** The last config received over MQTT, or the seed. Kept across restarts (FR-14). */
var configJson: String?
get() = prefs.getString(KEY_CONFIG, null)
set(value) = prefs.edit().putString(KEY_CONFIG, value).apply()
/** The service writes this every watchdog tick, so a force-stop leaves a gap. */
var heartbeatAt: Long
get() = prefs.getLong(KEY_HEARTBEAT, 0L)
set(value) = prefs.edit().putLong(KEY_HEARTBEAT, value).apply()
var heartbeatBoot: Int
get() = prefs.getInt(KEY_HEARTBEAT_BOOT, -1)
set(value) = prefs.edit().putInt(KEY_HEARTBEAT_BOOT, value).apply()
var armed: Boolean
get() = prefs.getBoolean(KEY_ARMED, false)
set(value) = prefs.edit().putBoolean(KEY_ARMED, value).apply()
var lastTamperKind: String
get() = prefs.getString(KEY_TAMPER_KIND, "")!!
set(value) = prefs.edit().putString(KEY_TAMPER_KIND, value).apply()
var lastTamperAt: Long
get() = prefs.getLong(KEY_TAMPER_AT, 0L)
set(value) = prefs.edit().putLong(KEY_TAMPER_AT, value).apply()
var lastTamperDetail: String
get() = prefs.getString(KEY_TAMPER_DETAIL, "")!!
set(value) = prefs.edit().putString(KEY_TAMPER_DETAIL, value).apply()
fun statusTopic(): String = deviceTopic.removeSuffix("/config") + "/status"
/** The middle topic level, which is also the broker account name. */
fun topicToken(): String =
deviceTopic.removeSuffix("/config").substringAfterLast('/')
fun bootCount(): Int =
try {
Settings.Global.getInt(
appContext.contentResolver,
Settings.Global.BOOT_COUNT,
-1,
)
} catch (e: Exception) {
Log.w(TAG, "cannot read boot count: ${e.message}")
-1
}
// A Context is needed for bootCount; hold the application context.
lateinit var appContext: Context
private set
init {
appContext = context.applicationContext
}
companion object {
private const val TAG = "WebRestrictPrefs"
private const val FILE = "webrestrict_kids"
private const val KEY_TOPIC = "device_topic"
private const val KEY_DEVICE_NAME = "device_name"
private const val KEY_BROKER_HOST = "broker_host"
private const val KEY_BROKER_PORT = "broker_port"
private const val KEY_MQTT_USER = "mqtt_username"
private const val KEY_MQTT_PASSWORD = "mqtt_password"
private const val KEY_CA_ASSET = "ca_asset"
private const val KEY_CONFIG = "config_json"
private const val KEY_HEARTBEAT = "heartbeat_at"
private const val KEY_HEARTBEAT_BOOT = "heartbeat_boot"
private const val KEY_ARMED = "armed"
private const val KEY_TAMPER_KIND = "tamper_kind"
private const val KEY_TAMPER_AT = "tamper_at"
private const val KEY_TAMPER_DETAIL = "tamper_detail"
}
}
/**
* Loads and saves the device config. A config that cannot be parsed is ignored,
* so a broker bug can never leave the device with no rules (FR-14).
*/
class ConfigStore(context: Context) {
private val prefs = AppPrefs(context)
/** Returns the last-known config, seeding the local default if there is none. */
fun load(): DeviceConfig {
val json = prefs.configJson ?: return DeviceConfig.default().also { save(it) }
return try {
DeviceConfig.fromJson(json)
} catch (e: Exception) {
Log.w(TAG, "stored config is unreadable, keeping the default: ${e.message}")
DeviceConfig.default()
}
}
fun save(config: DeviceConfig) {
prefs.configJson = config.toJson()
}
/** Writes the local default only when nothing is stored yet. */
fun seedIfEmpty() {
if (prefs.configJson == null) {
save(DeviceConfig.default())
}
}
companion object {
private const val TAG = "WebRestrictConfig"
}
}

View File

@@ -0,0 +1,141 @@
package au.audasmedia.webrestrict.kids
import android.accessibilityservice.AccessibilityService
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.util.Log
import android.view.accessibility.AccessibilityEvent
/**
* FR-16 + FR-17. Watches the foreground package and interposes the block
* overlay, then reports its own removal.
*
* This is deliberately a normal Accessibility Service: no Device Owner, no
* managed profile, no factory reset. A child can turn it off, and when they do
* the tamper reporter tells the Control Service (FR-17). The service never
* edits its own rules; it obeys the config the Control Service publishes
* (AGENTS.md §10 rule 7).
*/
class BlockAccessibilityService : AccessibilityService() {
private lateinit var prefs: AppPrefs
private lateinit var configs: ConfigStore
private lateinit var overlay: OverlayController
private val watchdog = Handler(Looper.getMainLooper())
private var foregroundPackage: String? = null
private var overlayReported = false
private val watchdogTick = object : Runnable {
override fun run() {
beat()
checkOverlayPermission()
watchdog.postDelayed(this, WATCHDOG_MS)
}
}
override fun onServiceConnected() {
super.onServiceConnected()
instance = this
prefs = AppPrefs(this)
configs = ConfigStore(this)
overlay = OverlayController(this)
// Arm force-stop detection: from now on, a heartbeat gap means a kill.
prefs.armed = true
prefs.heartbeatAt = System.currentTimeMillis()
prefs.heartbeatBoot = prefs.bootCount()
MqttController.setConfigListener { config ->
Log.i(TAG, "re-evaluating after config update locked=${config.locked}")
evaluate(foregroundPackage)
}
MqttController.start(this)
MqttController.publishStatus(this, foregroundPackage)
watchdog.removeCallbacks(watchdogTick)
watchdog.post(watchdogTick)
Log.i(TAG, "accessibility service connected")
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event == null) return
// Only a window state change means "this app is now in front". The
// windows-changed event fires for every window in the stack, including
// transient ones, and would flap the overlay off.
if (event.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return
val pkg = event.packageName?.toString() ?: return
// Our own window (the overlay) is not a foreground app to judge.
if (pkg == packageName) return
// System UI and the system itself are not the app the kid chose.
if (pkg == "com.android.systemui" || pkg == "android") return
if (pkg != foregroundPackage) {
foregroundPackage = pkg
MqttController.publishStatus(this, pkg)
}
evaluate(pkg)
}
/** Applies the block decision for one package. */
private fun evaluate(pkg: String?) {
val config = configs.load()
val decision = BlockEngine.decide(pkg, config, packageName)
Log.d(TAG, "evaluate pkg=$pkg locked=${config.locked} blocked=${decision.blocked}")
if (decision.blocked) {
overlay.show(decision)
} else {
overlay.hide()
}
}
override fun onInterrupt() {
// The system pauses the service (for example another service took over).
// Not a tamper by itself, but worth a line in the log.
Log.w(TAG, "accessibility service interrupted; keeping the last config")
}
override fun onUnbind(intent: Intent?): Boolean {
TamperReporter.report(this, "accessibility_disabled", "the service was unbound")
return super.onUnbind(intent)
}
override fun onDestroy() {
TamperReporter.report(this, "accessibility_disabled", "the service was destroyed")
watchdog.removeCallbacks(watchdogTick)
overlay.hide()
instance = null
Log.i(TAG, "accessibility service destroyed")
super.onDestroy()
}
private fun beat() {
prefs.heartbeatAt = System.currentTimeMillis()
prefs.heartbeatBoot = prefs.bootCount()
}
/**
* The overlay permission can be revoked while the service stays alive. That
* is tampering with a permission this client depends on, so report it once
* per transition (FR-17).
*/
private fun checkOverlayPermission() {
val granted = Settings.canDrawOverlays(this)
if (!granted && !overlayReported) {
overlayReported = true
TamperReporter.report(this, "overlay_revoked", "SYSTEM_ALERT_WINDOW is no longer granted")
} else if (granted) {
overlayReported = false
}
}
companion object {
private const val TAG = "WebRestrictService"
private const val WATCHDOG_MS = 15_000L
/** The live service, or null. MainActivity reads this for its status screen. */
@Volatile
var instance: BlockAccessibilityService? = null
}
}

View File

@@ -0,0 +1,47 @@
package au.audasmedia.webrestrict.kids
/**
* The decision for one foreground package.
*
* [value] is the blocklist entry that matched, so the overlay can name what is
* blocked. [reason] and [until] come from the config, so the overlay can say
* why and until when.
*/
data class BlockDecision(
val blocked: Boolean,
val value: String? = null,
val reason: String? = null,
val until: String? = null,
) {
companion object {
val Allowed = BlockDecision(blocked = false)
}
}
/**
* Pure block logic, unit tested without an emulator.
*
* Blocked means: the config is locked, the foreground package is on the
* blocklist, and it is not on the allowlist. The client never widens or
* narrows the rules itself (AGENTS.md §10 rule 7).
*/
object BlockEngine {
fun decide(packageName: String?, config: DeviceConfig, selfPackage: String): BlockDecision {
if (packageName.isNullOrBlank()) return BlockDecision.Allowed
// Never block ourselves: the overlay belongs to this package, and the
// status screen must stay reachable.
if (packageName == selfPackage) return BlockDecision.Allowed
// The allowlist is honoured during a block (FR-4).
if (config.allow.contains(packageName)) return BlockDecision.Allowed
if (!config.locked) return BlockDecision.Allowed
if (config.block.contains(packageName)) {
return BlockDecision(
blocked = true,
value = packageName,
reason = config.reason,
until = config.until,
)
}
return BlockDecision.Allowed
}
}

View File

@@ -0,0 +1,27 @@
package au.audasmedia.webrestrict.kids
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
/**
* After a reboot or an app update, reconnect to the broker so the retained
* config arrives at once (FR-5, FR-14). The system owns the Accessibility
* Service, so this receiver does not try to start it.
*/
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (context == null) return
Log.i("WebRestrictBoot", "boot/update event: ${intent?.action}")
try {
// A reboot is not a force-stop; reset the heartbeat so the next
// app start does not mistake it for one.
val prefs = AppPrefs(context)
prefs.heartbeatAt = 0L
MqttController.start(context)
} catch (e: Exception) {
Log.w("WebRestrictBoot", "post-boot start failed: ${e.message}")
}
}
}

View File

@@ -0,0 +1,61 @@
package au.audasmedia.webrestrict.kids
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
/**
* The desired state for one device, exactly as the Control Service publishes it
* retained on `restrict/<device>/config` (TRANSPORT_DESIGN.md, Phase 3).
*
* [locked] and [until] are the effective state. While [locked] is true the
* client blocks every [block] value except the [allow] values (FR-4). Unknown
* fields are ignored, so the payload may grow without a client update.
*/
data class DeviceConfig(
val v: Int = 1,
@SerializedName("device_id") val deviceId: Long = 0,
val name: String = "",
val platform: String = "android",
val locked: Boolean = false,
val until: String? = null,
val allow: List<String> = emptyList(),
val block: List<String> = emptyList(),
val reason: String? = null,
@SerializedName("grant_until") val grantUntil: String? = null,
@SerializedName("generated_at") val generatedAt: String? = null,
) {
/** The effective [block] list minus every [allow] value. */
fun effectiveBlock(): List<String> = block.filterNot { it in allow }
fun toJson(): String = gson.toJson(this)
companion object {
private val gson = Gson()
/** Parses a config payload. Throws on malformed JSON so the caller can keep the last one. */
fun fromJson(json: String): DeviceConfig =
gson.fromJson(json, DeviceConfig::class.java) ?: default()
/**
* The local fallback used until the broker delivers a retained config
* (FR-14 "keep last state", and the accepted emulator default). It
* blocks the Settings app so the block path is visible on a stock AVD.
*/
fun default(): DeviceConfig = DeviceConfig(
v = 1,
deviceId = 0,
name = BuildConfig.DEFAULT_DEVICE_NAME,
platform = "android",
locked = true,
until = null,
reason = "schedule",
allow = emptyList(),
block = listOf(
"com.android.settings",
"com.google.android.youtube",
"com.android.chrome",
),
generatedAt = null,
)
}
}

View File

@@ -0,0 +1,26 @@
package au.audasmedia.webrestrict.kids
import android.app.Application
import android.util.Log
/**
* The client process. It seeds the local default config and checks whether the
* previous run was killed, before the Accessibility Service even connects.
*/
class KidsApp : Application() {
override fun onCreate() {
super.onCreate()
try {
ConfigStore(this).seedIfEmpty()
TamperReporter.detectForceStop(this)
MqttController.start(this)
} catch (e: Exception) {
// A failure here must never turn on the crash dialog for a kid.
Log.e(TAG, "startup failed: ${e.message}")
}
}
companion object {
private const val TAG = "WebRestrictApp"
}
}

View File

@@ -0,0 +1,189 @@
package au.audasmedia.webrestrict.kids
import android.content.ComponentName
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.text.InputType
import android.util.TypedValue
import android.view.Gravity
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* The device status screen for Sam (and a technical view for the kid). It shows
* what is blocked, until when, and the health of the two things the client
* depends on: the Accessibility Service and the overlay permission.
*
* It never edits the rules; those come from the Control Service.
*/
class MainActivity : AppCompatActivity() {
private lateinit var container: LinearLayout
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
container = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(20), dp(20), dp(20), dp(20))
}
setContentView(ScrollView(this).apply { addView(container) })
refresh()
}
override fun onResume() {
super.onResume()
refresh()
}
private fun refresh() {
container.removeAllViews()
val prefs = AppPrefs(this)
val config = ConfigStore(this).load()
title("Screen Limits")
subtitle("This phone obeys the family device rules.")
section("Device")
row("Name", prefs.deviceName)
row("Topic", prefs.deviceTopic)
row("Broker", "${prefs.brokerHost}:${prefs.brokerPort} (TLS)")
row("Broker link", if (MqttController.isConnected()) "connected" else "offline (keeping last config)")
section("Now")
row("State", if (config.locked) "blocked" else "allowed")
row("Reason", config.reason ?: "—")
row("Until", config.until ?: "no end time")
row("Blocked apps", config.effectiveBlock().joinToString(", ").ifBlank { "none" })
row("Allowed during a block", config.allow.joinToString(", ").ifBlank { "none" })
section("Enforcement")
val accessibility = isAccessibilityEnabled()
row("Accessibility Service", if (accessibility) "enabled" else "DISABLED — tamper reported")
row("Overlay permission", if (Settings.canDrawOverlays(this)) "granted" else "REVOKED — tamper reported")
val tamperAt = prefs.lastTamperAt
row(
"Last tamper",
if (tamperAt <= 0) "none"
else "${prefs.lastTamperKind} at ${time(tamperAt)} (${prefs.lastTamperDetail})",
)
section("Actions")
button("Open Accessibility settings") {
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
}
button("Grant overlay permission") {
startActivity(
Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:$packageName"),
),
)
}
button("Reload the local default config") {
ConfigStore(this).save(DeviceConfig.default())
refresh()
}
button("Report a test tamper") {
TamperReporter.report(this, "accessibility_disabled", "manual test from the status screen")
refresh()
}
section("Edit broker settings (lab)")
field("Broker host", prefs.brokerHost) { prefs.brokerHost = it; MqttController.stop(); MqttController.start(this) }
field("Broker port", prefs.brokerPort.toString()) {
it.toIntOrNull()?.let { port -> prefs.brokerPort = port }
}
field("Device config topic", prefs.deviceTopic) { prefs.deviceTopic = it }
field("MQTT username", prefs.mqttUsername) { prefs.mqttUsername = it }
field("MQTT password", prefs.mqttPassword) { prefs.mqttPassword = it; MqttController.stop(); MqttController.start(this) }
}
private fun isAccessibilityEnabled(): Boolean {
val expected = ComponentName(this, BlockAccessibilityService::class.java)
val enabled = Settings.Secure.getString(
contentResolver,
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
) ?: return false
return enabled.split(':').any { entry ->
val cn = ComponentName.unflattenFromString(entry) ?: return@any false
cn.packageName == expected.packageName && cn.className == expected.className
}
}
// --- tiny view builders -------------------------------------------------
private fun title(value: String) {
container.addView(TextView(this).apply {
text = value
setTextSize(TypedValue.COMPLEX_UNIT_SP, 26f)
setTextColor(Color.parseColor("#111418"))
setPadding(0, 0, 0, dp(4))
})
}
private fun subtitle(value: String) {
container.addView(TextView(this).apply {
text = value
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
setTextColor(Color.parseColor("#6b7280"))
setPadding(0, 0, 0, dp(12))
})
}
private fun section(value: String) {
container.addView(TextView(this).apply {
text = value
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
setTextColor(Color.parseColor("#0075de"))
setPadding(0, dp(18), 0, dp(6))
})
}
private fun row(label: String, value: String) {
container.addView(TextView(this).apply {
text = "$label: $value"
setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f)
setTextColor(Color.parseColor("#111418"))
setPadding(0, dp(3), 0, dp(3))
})
}
private fun button(label: String, action: () -> Unit) {
container.addView(Button(this).apply {
text = label
gravity = Gravity.START
setOnClickListener { action() }
})
}
private fun field(label: String, value: String, onChange: (String) -> Unit) {
container.addView(TextView(this).apply {
text = label
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
setTextColor(Color.parseColor("#6b7280"))
setPadding(0, dp(8), 0, 0)
})
val input = EditText(this).apply {
setText(value)
inputType = InputType.TYPE_CLASS_TEXT
}
input.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) onChange(input.text.toString()) }
container.addView(input)
}
private fun time(millis: Long): String =
SimpleDateFormat("HH:mm:ss", Locale.US).format(Date(millis))
private fun dp(value: Int): Int =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), resources.displayMetrics).toInt()
}

View File

@@ -0,0 +1,276 @@
package au.audasmedia.webrestrict.kids
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.google.gson.Gson
import org.eclipse.paho.client.mqttv3.IMqttActionListener
import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken
import org.eclipse.paho.client.mqttv3.IMqttToken
import org.eclipse.paho.client.mqttv3.MqttAsyncClient
import org.eclipse.paho.client.mqttv3.MqttCallbackExtended
import org.eclipse.paho.client.mqttv3.MqttConnectOptions
import org.eclipse.paho.client.mqttv3.MqttMessage
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence
import java.io.InputStream
import java.security.KeyStore
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.Executors
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManagerFactory
/**
* The Phase 3 transport, on the device side (AGENTS.md §10 rule 6).
*
* Subscribes to the retained `restrict/<device>/config` topic over the 8883 TLS
* plane with the device's own credentials, and publishes presence plus the
* tamper report on `restrict/<device>/status`. It never publishes config: the
* broker ACL would drop it, and the client only obeys (rule 7).
*
* A broker that is down is not fatal. The last config stays on disk (FR-14) and
* the connect loop backs off and retries. This is a deliberate simplification of
* the real client; the emulator proof never reaches a broker.
*/
object MqttController {
private const val TAG = "WebRestrictMqtt"
private const val QOS = 1
private const val RETRY_MS = 30_000L
private val gson = Gson()
private val executor = Executors.newSingleThreadExecutor { r ->
Thread(r, "webrestrict-mqtt").apply { isDaemon = true }
}
private val main = Handler(Looper.getMainLooper())
@Volatile
private var client: MqttAsyncClient? = null
@Volatile
private var connected = false
@Volatile
private var lastError: String? = null
@Volatile
private var configListener: ((DeviceConfig) -> Unit)? = null
fun isConnected(): Boolean = connected
fun lastError(): String? = lastError
/** Registers the callback for a config that arrives over MQTT. */
fun setConfigListener(listener: (DeviceConfig) -> Unit) {
configListener = listener
}
/** Starts the link once. Safe to call from Application, Activity and the service. */
fun start(context: Context) {
if (client != null) return
val app = context.applicationContext
executor.execute {
if (client != null) return@execute
try {
val prefs = AppPrefs(app)
val uri = "ssl://${prefs.brokerHost}:${prefs.brokerPort}"
val c = MqttAsyncClient(uri, "kid-${prefs.topicToken()}", MemoryPersistence())
c.setCallback(callback(app, c, prefs))
client = c
connect(app, c, prefs)
} catch (e: Exception) {
lastError = e.message
Log.w(TAG, "mqtt init failed: ${e.message}")
scheduleRetry(app)
}
}
}
/** Disconnects and forgets the client (used by tests and a config change). */
fun stop() {
val c = client ?: return
client = null
connected = false
executor.execute {
try {
c.disconnectForcibly(0, 0)
c.close()
} catch (e: Exception) {
Log.w(TAG, "mqtt close: ${e.message}")
}
}
}
private fun connect(app: Context, c: MqttAsyncClient, prefs: AppPrefs) {
val options = MqttConnectOptions().apply {
isAutomaticReconnect = true
isCleanSession = true
keepAliveInterval = 30
connectionTimeout = 10
userName = prefs.mqttUsername.ifBlank { null }
if (prefs.mqttPassword.isNotEmpty()) password = prefs.mqttPassword.toCharArray()
// Presence: the broker publishes this the moment the link drops
// without a clean DISCONNECT (FR-6).
setWill(prefs.statusTopic(), ONLINE_WILL, QOS, true)
}
tlsSocketFactory(app, prefs)?.let { options.socketFactory = it }
try {
c.connect(options, null, object : IMqttActionListener {
override fun onSuccess(token: IMqttToken?) {
connected = true
lastError = null
Log.i(TAG, "mqtt connected to ${prefs.brokerHost}:${prefs.brokerPort}")
subscribe(c, prefs)
publishStatus(app, null)
}
override fun onFailure(token: IMqttToken?, exception: Throwable?) {
connected = false
lastError = exception?.message
Log.w(TAG, "mqtt connect failed: ${exception?.message}")
scheduleRetry(app)
}
})
} catch (e: Exception) {
lastError = e.message
Log.w(TAG, "mqtt connect: ${e.message}")
scheduleRetry(app)
}
}
private fun subscribe(c: MqttAsyncClient, prefs: AppPrefs) {
try {
c.subscribe(prefs.deviceTopic, QOS)
Log.i(TAG, "subscribed to ${prefs.deviceTopic}")
} catch (e: Exception) {
Log.w(TAG, "mqtt subscribe: ${e.message}")
}
}
private fun callback(app: Context, c: MqttAsyncClient, prefs: AppPrefs) =
object : MqttCallbackExtended {
override fun connectComplete(reconnect: Boolean, serverURI: String?) {
connected = true
lastError = null
Log.i(TAG, "mqtt connectComplete reconnect=$reconnect")
subscribe(c, prefs)
publishStatus(app, null)
}
override fun connectionLost(cause: Throwable?) {
connected = false
lastError = cause?.message
Log.w(TAG, "mqtt connection lost: ${cause?.message}")
}
override fun messageArrived(topic: String?, message: MqttMessage?) {
if (topic == null || message == null) return
if (!topic.endsWith("/config")) return
val json = String(message.payload, Charsets.UTF_8)
try {
val config = DeviceConfig.fromJson(json)
ConfigStore(app).save(config)
Log.i(TAG, "config received locked=${config.locked} until=${config.until}")
main.post { configListener?.invoke(config) }
} catch (e: Exception) {
Log.w(TAG, "config rejected, keeping the last one: ${e.message}")
}
}
override fun deliveryComplete(token: IMqttDeliveryToken?) {}
}
/** Publishes the presence status, optionally naming the foreground app. */
fun publishStatus(context: Context, foregroundApp: String?) {
val prefs = AppPrefs(context)
val payload = StatusPayload(
online = true,
app = foregroundApp,
locked = ConfigStore(context).load().locked,
at = isoNow(),
)
publish(prefs.statusTopic(), payload, retained = true)
}
/** Publishes the tamper report on the same status topic (FR-17). */
fun publishTamper(context: Context, kind: String, detail: String) {
val prefs = AppPrefs(context)
val payload = StatusPayload(
online = true,
at = isoNow(),
tamper = TamperPayload(kind = kind, detail = detail, at = isoNow()),
)
publish(prefs.statusTopic(), payload, retained = true)
}
private fun publish(topic: String, payload: Any, retained: Boolean) {
val c = client ?: return
if (!connected) return
try {
val message = MqttMessage(gson.toJson(payload).toByteArray(Charsets.UTF_8)).apply {
qos = QOS
isRetained = retained
}
c.publish(topic, message)
} catch (e: Exception) {
Log.w(TAG, "mqtt publish to $topic: ${e.message}")
}
}
/** Retries the initial connection after a failure. A successful connect is
* re-handled by Paho's automatic reconnect; an initial failure is not. */
private fun scheduleRetry(context: Context) {
val app = context.applicationContext
main.postDelayed({
executor.execute {
val c = client ?: return@execute
connect(app, c, AppPrefs(app))
}
}, RETRY_MS)
}
/**
* Builds the TLS socket factory. With no CA asset configured the platform
* trust store is used, which is what a public broker would need. A lab CA is
* loaded from assets so the broker's own certificate verifies.
*/
private fun tlsSocketFactory(context: Context, prefs: AppPrefs): SSLSocketFactory? {
if (prefs.caAsset.isBlank()) return null
return try {
val certs = context.assets.open(prefs.caAsset).use(::readCertificates)
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply {
load(null, null)
certs.forEachIndexed { i, cert -> setCertificateEntry("ca$i", cert) }
}
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
tmf.init(keyStore)
SSLContext.getInstance("TLS").apply { init(null, tmf.trustManagers, null) }.socketFactory
} catch (e: Exception) {
Log.w(TAG, "cannot load CA asset ${prefs.caAsset}: ${e.message}")
null
}
}
private fun readCertificates(input: InputStream): List<X509Certificate> =
CertificateFactory.getInstance("X.509").generateCertificates(input).map { it as X509Certificate }
private fun isoNow(): String =
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).format(Date())
private val ONLINE_WILL = "{\"online\":false}".toByteArray(Charsets.UTF_8)
private data class TamperPayload(val kind: String, val detail: String?, val at: String)
private data class StatusPayload(
val online: Boolean,
val app: String? = null,
val locked: Boolean? = null,
val at: String,
val tamper: TamperPayload? = null,
)
}

View File

@@ -0,0 +1,179 @@
package au.audasmedia.webrestrict.kids
import android.content.Context
import android.graphics.Color
import android.graphics.PixelFormat
import android.graphics.Typeface
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.view.WindowManager
import android.widget.LinearLayout
import android.widget.TextView
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.ZoneId
import java.util.Date
import java.util.Locale
/**
* The full-screen block overlay (FR-16). It is an ordinary
* `TYPE_APPLICATION_OVERLAY` window, so it needs the overlay permission and
* covers the blocked app completely. It only blocks; it never changes a rule.
*
* Every call is posted to the main looper because window changes must run there
* and the caller may be on the MQTT thread.
*/
class OverlayController(private val context: Context) {
private val windowManager =
context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private val main = Handler(Looper.getMainLooper())
private var view: View? = null
fun show(decision: BlockDecision) {
val label = appLabel(decision.value)
main.post { showOnMain(decision, label) }
}
fun hide() {
main.post { hideOnMain() }
}
fun isShowing(): Boolean = view != null
private fun showOnMain(decision: BlockDecision, label: String) {
if (view != null) return
val root = buildView(decision, label)
// TYPE_ACCESSIBILITY_OVERLAY is deliberately preferred over
// TYPE_APPLICATION_OVERLAY. Android force-hides non-system overlays
// while a window with HIDE_NON_SYSTEM_OVERLAY_WINDOWS is on top (some
// Settings screens and system dialogs set it), which would leave a
// blocked app usable. An accessibility overlay is trusted, needs no
// SYSTEM_ALERT_WINDOW, and cannot be hidden that way. The overlay
// permission is still required as a setup grant and its revocation is
// reported (FR-17).
val type = WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY
val params = WindowManager.LayoutParams(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT,
type,
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
PixelFormat.TRANSLUCENT,
).apply {
gravity = Gravity.TOP or Gravity.START
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
try {
windowManager.addView(root, params)
view = root
Log.i(TAG, "block overlay shown for ${decision.value} (type=$type until=${decision.until})")
} catch (e: Exception) {
Log.w(TAG, "cannot add accessibility overlay: ${e.message}")
}
}
private fun hideOnMain() {
val v = view ?: return
view = null
try {
windowManager.removeView(v)
Log.i(TAG, "block overlay hidden")
} catch (e: Exception) {
Log.w(TAG, "cannot remove overlay: ${e.message}")
}
}
private fun buildView(decision: BlockDecision, label: String): View {
val root = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
setBackgroundColor(Color.parseColor("#F2101418"))
setPadding(dp(32), dp(32), dp(32), dp(32))
isClickable = true
isFocusable = true
setOnClickListener { /* consume taps on the blocked app */ }
}
root.addView(text("Blocked", 34f, Color.WHITE, Typeface.BOLD))
root.addView(text(label, 22f, Color.parseColor("#9fd0ff"), Typeface.NORMAL).withTopMargin())
root.addView(text(reasonLine(decision.reason), 16f, Color.parseColor("#d8dde3"), Typeface.NORMAL).withTopMargin())
decision.until?.let { until ->
humanUntil(until)?.let {
root.addView(text("Until $it", 16f, Color.parseColor("#d8dde3"), Typeface.NORMAL).withTopMargin())
}
}
root.addView(
text(
"Ask a parent if you need more time.",
14f,
Color.parseColor("#8b949e"),
Typeface.NORMAL,
).withTopMargin(),
)
return root
}
private fun text(value: String, sizeSp: Float, color: Int, style: Int): TextView =
TextView(context).apply {
text = value
setTextColor(color)
setTextSize(TypedValue.COMPLEX_UNIT_SP, sizeSp)
typeface = Typeface.create("sans-serif", style)
gravity = Gravity.CENTER
}
private fun TextView.withTopMargin(): TextView = apply {
(layoutParams as? LinearLayout.LayoutParams)?.let { it.topMargin = dp(12) }
?: run {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
).apply { topMargin = dp(12) }
}
}
private fun reasonLine(reason: String?): String = when (reason) {
"schedule" -> "This app is blocked by the family schedule."
"manual" -> "A parent locked this app."
"grant" -> "This app is blocked again."
else -> "This app is blocked."
}
private fun humanUntil(until: String?): String? {
if (until.isNullOrBlank()) return null
val millis = try {
Instant.parse(until).toEpochMilli()
} catch (e: Exception) {
// Anything the service sends that is not RFC3339 is shown as-is.
return until
}
val at = java.time.ZonedDateTime.ofInstant(Instant.ofEpochMilli(millis), ZoneId.systemDefault())
val clock = SimpleDateFormat("h:mm a", Locale.getDefault()).format(Date(millis))
return "$clock on ${at.dayOfWeek.name.lowercase().replaceFirstChar { c -> c.uppercase() }}"
}
private fun appLabel(packageName: String?): String {
if (packageName.isNullOrBlank()) return "This app"
return try {
val pm = context.packageManager
pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString()
} catch (e: Exception) {
packageName
}
}
private fun dp(value: Int): Int =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), context.resources.displayMetrics).toInt()
companion object {
private const val TAG = "WebRestrictOverlay"
}
}

View File

@@ -0,0 +1,78 @@
package au.audasmedia.webrestrict.kids
import android.content.Context
import android.util.Log
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* FR-17: the real enforcement. A child can disable the Accessibility Service,
* revoke the overlay permission, or force-stop the app; when any of those
* happens the client says so immediately, over the one channel its broker ACL
* allows (its own status topic), so the Control Service can alert Sam.
*
* The report is always written to logcat under [TAG] and appended to
* `files/tamper.log`, so it survives a broker outage. It is published over
* MQTT when the link is up.
*/
object TamperReporter {
const val TAG = "WebRestrictTamper"
private const val DEDUPE_WINDOW_MS = 3_000L
/** Reports one tamper event. Duplicate reports within [DEDUPE_WINDOW_MS] are dropped. */
@Synchronized
fun report(context: Context, kind: String, detail: String) {
val now = System.currentTimeMillis()
val prefs = AppPrefs(context)
if (prefs.lastTamperKind == kind && now - prefs.lastTamperAt < DEDUPE_WINDOW_MS) {
return
}
prefs.lastTamperKind = kind
prefs.lastTamperAt = now
prefs.lastTamperDetail = detail
val stamp = isoNow(now)
Log.e(TAG, "TAMPER kind=$kind detail=\"$detail\" at=$stamp")
append(context, "$stamp kind=$kind detail=$detail")
MqttController.publishTamper(context, kind, detail)
}
/**
* Force-stop detection. The service writes a heartbeat every watchdog tick.
* If the app starts again and the heartbeat stopped a while ago, on the same
* boot, the process was killed rather than restarted normally. A reboot is
* excluded by comparing the boot count.
*/
fun detectForceStop(context: Context) {
val prefs = AppPrefs(context)
if (!prefs.armed) return
val last = prefs.heartbeatAt
if (last <= 0L) return
val gap = System.currentTimeMillis() - last
val sameBoot = prefs.bootCount() == prefs.heartbeatBoot
if (sameBoot && gap in MIN_GAP_MS..MAX_GAP_MS) {
report(context, "force_stopped", "no heartbeat for ${gap / 1000}s on the same boot")
}
}
private fun append(context: Context, line: String) {
try {
val file = File(context.filesDir, "tamper.log")
file.appendText(line + "\n")
} catch (e: Exception) {
Log.w(TAG, "cannot append tamper log: ${e.message}")
}
}
private fun isoNow(now: Long): String =
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).format(Date(now))
// A normal process restart reconnects within a tick or two; a kill leaves a
// much larger gap. Anything past MAX_GAP_MS is treated as the phone being
// off rather than tampering, to avoid a false alarm.
private const val MIN_GAP_MS = 2 * 60 * 1000L
private const val MAX_GAP_MS = 12 * 60 * 60 * 1000L
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A minimal shield mark. No binary art in the repo. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#0075de"
android:pathData="M54,14 L88,28 L88,54 C88,74 73,88 54,94 C35,88 20,74 20,54 L20,28 Z" />
<path
android:fillColor="#ffffff"
android:pathData="M48,60 L40,52 L34,58 L48,72 L74,44 L68,38 Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.WebRestrict" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="colorPrimary">#0075de</item>
<item name="colorPrimaryDark">#0059a8</item>
<item name="colorAccent">#0075de</item>
<item name="android:windowBackground">#121212</item>
</style>
</resources>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Screen Limits</string>
<string name="accessibility_label">Screen Limits blocks apps</string>
<string name="accessibility_description">Screen Limits uses this service to see which app is in the
foreground, so it can block an app on the family blocklist while a limit is active. It does
not read the screen or your typing.</string>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Family Home Lab tokens: canvas #f6f5f4, action blue #0075de. -->
<style name="Theme.WebRestrict" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="colorPrimary">#0075de</item>
<item name="colorPrimaryDark">#0059a8</item>
<item name="colorAccent">#0075de</item>
<item name="android:windowBackground">#f6f5f4</item>
</style>
</resources>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The Accessibility Service contract. It watches window-state changes to learn
the foreground package, and nothing else: canRetrieveWindowContent is false,
so the app never reads the screen's text.
-->
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault"
android:canRetrieveWindowContent="false"
android:description="@string/accessibility_description"
android:notificationTimeout="100"
android:settingsActivity="au.audasmedia.webrestrict.kids.MainActivity" />

View File

@@ -0,0 +1,61 @@
package au.audasmedia.webrestrict.kids
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/** The block rule, tested without an emulator (FR-4, FR-16). */
class BlockEngineTest {
private val self = "au.audasmedia.webrestrict.kids"
@Test
fun `blocks a listed app while locked`() {
val config = DeviceConfig(locked = true, block = listOf("com.android.settings"), reason = "schedule")
val decision = BlockEngine.decide("com.android.settings", config, self)
assertTrue(decision.blocked)
assertEquals("com.android.settings", decision.value)
assertEquals("schedule", decision.reason)
}
@Test
fun `does not block while unlocked`() {
val config = DeviceConfig(locked = false, block = listOf("com.android.settings"))
assertFalse(BlockEngine.decide("com.android.settings", config, self).blocked)
}
@Test
fun `allowlist wins over the blocklist`() {
val config = DeviceConfig(
locked = true,
block = listOf("com.spotify.music", "com.youtube.android"),
allow = listOf("com.spotify.music"),
)
assertFalse(BlockEngine.decide("com.spotify.music", config, self).blocked)
assertTrue(BlockEngine.decide("com.youtube.android", config, self).blocked)
}
@Test
fun `an unlisted app is not blocked`() {
val config = DeviceConfig(locked = true, block = listOf("com.android.settings"))
assertFalse(BlockEngine.decide("com.example.school", config, self).blocked)
}
@Test
fun `never blocks itself and ignores a blank package`() {
val config = DeviceConfig(locked = true, block = listOf(self))
assertFalse(BlockEngine.decide(self, config, self).blocked)
assertFalse(BlockEngine.decide(null, config, self).blocked)
assertFalse(BlockEngine.decide("", config, self).blocked)
}
@Test
fun `effective block list removes allowed values`() {
val config = DeviceConfig(
block = listOf("a", "b", "c"),
allow = listOf("b"),
)
assertEquals(listOf("a", "c"), config.effectiveBlock())
}
}

View File

@@ -0,0 +1,52 @@
package au.audasmedia.webrestrict.kids
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/** Parses the exact Phase 3 config payload shape (TRANSPORT_DESIGN.md). */
class DeviceConfigTest {
@Test
fun `parses the retained config payload`() {
val json = """
{"v":1,"device_id":1,"name":"Harry's phone","platform":"android",
"locked":true,"until":"2026-10-13T21:00:00Z",
"allow":["com.spotify.music"],"block":["com.youtube.android"],
"reason":"schedule","grant_until":null,
"generated_at":"2026-10-13T03:30:27Z"}
""".trimIndent()
val config = DeviceConfig.fromJson(json)
assertEquals(1, config.v)
assertEquals(1L, config.deviceId)
assertTrue(config.locked)
assertEquals("2026-10-13T21:00:00Z", config.until)
assertEquals("schedule", config.reason)
assertEquals(listOf("com.spotify.music"), config.allow)
assertEquals(listOf("com.youtube.android"), config.block)
}
@Test
fun `missing lists default to empty, never null`() {
val config = DeviceConfig.fromJson("""{"v":1,"locked":false}""")
assertEquals(emptyList<String>(), config.allow)
assertEquals(emptyList<String>(), config.block)
assertFalse(config.locked)
}
@Test
fun `round trips through json`() {
val original = DeviceConfig(locked = true, block = listOf("com.android.settings"), reason = "manual")
val copy = DeviceConfig.fromJson(original.toJson())
assertEquals(original, copy)
}
@Test
fun `the local default blocks an app on a stock emulator`() {
val config = DeviceConfig.default()
assertTrue(config.locked)
assertTrue(config.block.contains("com.android.settings"))
}
}