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

15
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
# Android / Gradle build output
build/
.gradle/
local.properties
*.apk
*.aab
*.keystore
*.jks
.cxx/
captures/
# IDE
.idea/
*.iml
.DS_Store

63
android/README.md Normal file
View File

@@ -0,0 +1,63 @@
# Kids' Android client — Phase 5.1
The core no-wipe block path of the kids' phone app (AGENTS.md §11 Phase 5,
FR-16 + FR-17). A normal Gradle project with its own wrapper. The toolchain is
**not** on `.13`; it builds on `.27`:
```bash
ssh 192.168.20.27 '~/android/build.sh internet_use_control'
```
That pulls this repo from Gitea and runs `./gradlew assembleDebug`. It prints
`APK=<path>`; the debug APK is `android/app/build/outputs/apk/debug/app-debug.apk`.
## What it does
| Piece | File | Job |
|---|---|---|
| Accessibility Service | `BlockAccessibilityService.kt` | Watches the foreground package (FR-16). |
| Block overlay | `OverlayController.kt` | A full-screen `TYPE_APPLICATION_OVERLAY` window over a blocked app. |
| Block rule | `BlockEngine.kt` | Blocked = locked **and** on the blocklist **and** not allowlisted (FR-4). |
| Config transport | `MqttController.kt` | Subscribes, retained, to `restrict/<device>/config` on the 8883 TLS plane with the device's own credentials; publishes presence and tamper on `restrict/<device>/status`. |
| Last state | `AppPrefs.kt` | The last config is kept on disk, so a broker-less device keeps its rules (FR-14). |
| Tamper alert | `TamperReporter.kt` | Reports a disabled service, a revoked overlay permission, or a force-stop (FR-17). |
| Status screen | `MainActivity.kt` | What is blocked, until when, and the health of the enforcement. |
**No Device Owner. No factory reset. No managed profile.** The child can disable
the client; the enforcement is the tamper alert, not the overlay.
## How the tamper alert reaches Sam
The device's broker ACL only lets it write its own `status` topic, so the tamper
report rides there:
```json
{"online":true,"tamper":{"kind":"accessibility_disabled","detail":"the service was destroyed","at":"2026-10-13T08:00:00Z"}}
```
`internal/mqtt` parses it, `internal/alert` records a `tamper` alert and delivers
it through Apprise, and `cmd/control` wires the two together.
## Test on the headless emulator
```bash
ssh 192.168.20.27 '~/android/emu.sh start'
ssh 192.168.20.27 '~/android/emu.sh wait'
ssh 192.168.20.27 '~/android/emu.sh install /tmp/webrestrict-pre/android/app/build/outputs/apk/debug/app-debug.apk'
PKG=au.audasmedia.webrestrict.kids
adb shell appops set $PKG SYSTEM_ALERT_WINDOW allow
adb shell settings put secure enabled_accessibility_services $PKG/$PKG.BlockAccessibilityService
adb shell settings put secure accessibility_enabled 1
# Open a blocked app (the local default blocks Settings) and screenshot.
adb shell am start -a android.settings.SETTINGS
sleep 3
ssh 192.168.20.27 '~/android/emu.sh screenshot /tmp/shot.png'
# Show the tamper alert: disable the service.
adb shell settings put secure accessibility_enabled 0
adb logcat -d -s WebRestrictTamper
ssh 192.168.20.27 '~/android/emu.sh stop'
```

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"))
}
}

5
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,5 @@
// Root build file. The Android application module is :app.
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
}

View File

@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

252
android/gradlew vendored Executable file
View File

@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,22 @@
// Internet Use Control — kids' Android client (AGENTS.md §11 Phase 5).
//
// The toolchain is NOT on .13. `~/android/build.sh internet_use_control` on .27
// pulls this repo and runs `./gradlew assembleDebug` in this directory.
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "WebRestrictKids"
include(":app")

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Emulator proof for the Phase 5.1 Android client. Runs on .27.
# bash emu-proof.sh <apk> <outdir>
set -uo pipefail
exec </dev/null
export ANDROID_HOME="$HOME/Android/Sdk"
export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"
PKG=au.audasmedia.webrestrict.kids
APK="${1:-/tmp/webrestrict-pre/android/app/build/outputs/apk/debug/app-debug.apk}"
OUT="${2:-/tmp/webrestrict}"
mkdir -p "$OUT"
# Headless test hygiene: a slow swiftshader emulator raises ANR dialogs, and an
# ANR dialog force-hides non-system overlay windows.
adb shell settings put global hide_error_dialogs 1
adb shell settings put global anr_show_background 0
adb shell svc power stayon true
adb shell input keyevent KEYCODE_WAKEUP
sleep 2
echo "== install =="
adb install -r "$APK" || exit 1
echo "== grant test permissions (adb, headless) =="
adb shell appops set $PKG SYSTEM_ALERT_WINDOW allow
adb shell appops set $PKG GET_USAGE_STATS allow
adb shell pm grant $PKG android.permission.POST_NOTIFICATIONS 2>/dev/null || true
# Toggle so the system binds the service even if it was already set.
adb shell settings put secure accessibility_enabled 0
adb shell settings delete secure enabled_accessibility_services 2>/dev/null || true
sleep 1
adb shell settings put secure enabled_accessibility_services $PKG/$PKG.BlockAccessibilityService
adb shell settings put secure accessibility_enabled 1
sleep 4
echo "a11y services: $(adb shell settings get secure enabled_accessibility_services)"
echo "a11y enabled: $(adb shell settings get secure accessibility_enabled)"
echo "overlay appop: $(adb shell appops get $PKG SYSTEM_ALERT_WINDOW)"
echo "== launch app once (seed config, start mqtt) =="
adb shell am start -n $PKG/.MainActivity
sleep 4
echo "== launch a blocked app: Settings =="
adb shell am force-stop com.android.settings
sleep 1
adb shell am start -a android.settings.SETTINGS
sleep 5
echo "== window state: overlay =="
adb shell dumpsys window windows | grep -i -A30 "u0 $PKG}:" | grep -E "Window #|ty=|isOnScreen|isVisible|mPolicyVisibility|shown=|mForceHide" | head -20
echo "current focus:"
adb shell dumpsys window | grep -E "mCurrentFocus" | head -2
echo "== screenshot: block overlay =="
adb exec-out screencap -p > "$OUT/block-overlay.png"
echo "wrote $OUT/block-overlay.png ($(stat -c%s "$OUT/block-overlay.png") bytes)"
echo "== logcat: service + overlay =="
adb logcat -d -s WebRestrictService WebRestrictOverlay WebRestrictConfig WebRestrictMqtt 2>/dev/null | tail -30
echo "== TAMPER: disable the Accessibility Service =="
adb shell settings put secure accessibility_enabled 0
adb shell settings delete secure enabled_accessibility_services 2>/dev/null || true
sleep 5
adb logcat -d -s WebRestrictTamper 2>/dev/null | tail -20 | tee "$OUT/tamper-logcat.txt"
echo "== tamper log file (persisted on device) =="
adb shell run-as $PKG cat files/tamper.log 2>/dev/null | tail -20 | tee "$OUT/tamper-file.txt" || true
echo "== screenshot: status screen after tamper =="
adb shell am start -n $PKG/.MainActivity
sleep 3
adb exec-out screencap -p > "$OUT/after-tamper.png"
echo "wrote $OUT/after-tamper.png ($(stat -c%s "$OUT/after-tamper.png") bytes)"
echo "== done =="