Kids' Android client — Phase 5 (5.1 + 5.2)
The no-wipe block path of the kids' phone app (AGENTS.md §11 Phase 5,
FR-4, FR-7, FR-14, FR-16, FR-17, FR-18). A normal Gradle project with its own
wrapper. The toolchain is not on .13; it builds on .27:
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_ACCESSIBILITY_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, and usage on restrict/<device>/telemetry. |
| 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, a force-stop or a revoked VPN (FR-17). |
| Status screen | MainActivity.kt |
What is blocked, until when, and the health of the enforcement. |
| Usage access | UsageCollector.kt |
Reads the OS per-app foreground total for the day (FR-7). |
| Local roll-up | TelemetryStore.kt |
Keeps the highest daily total per app across restarts (FR-7, FR-14). |
| Telemetry timer | TelemetryReporter.kt |
Samples every 60 s and publishes the day's totals. |
| Site filter | SiteFilterVpnService.kt |
A device-local VpnService that answers NXDOMAIN for a blocked host (FR-18). |
| Host matching | SiteFilter.kt |
Exact/subdomain matching; the allowlist always wins (FR-4). |
| DNS/IP wire format | DnsMessage.kt, IpPacket.kt |
Minimal parse/build for the DNS forwarder. |
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 telemetry path works (FR-7)
PACKAGE_USAGE_STATS is a privileged app-op, granted in Settings → Special app
access → Usage access, not at install. Every 60 s the reporter reads
UsageStatsManager.queryUsageStats(INTERVAL_DAILY, startOfDay, now), which is a
cumulative total for the day, merges it into the local roll-up (keeping the
higher value), and publishes the list form the Control Service accepts:
{"day":"2026-10-13","apps":[{"name":"com.android.chrome","seconds":120}]}
The message goes to restrict/<device>/telemetry over the 8883 TLS plane. It is
not retained. If the broker is down the roll-up stays on disk and is republished
as soon as the link returns (MqttController.setOnConnected).
How the site filter works (FR-18)
SiteFilterVpnService is an ordinary VpnService. It adds a fake resolver
address (10.111.222.2) and routes only that address into its own tunnel, so
normal traffic never enters it and no router, Pi-hole or DNS server is touched.
It reads UDP DNS on port 53, and:
- while the device is locked, a query whose name matches a blocklist entry (and not an allowlist entry) gets NXDOMAIN;
- every other query is forwarded to
1.1.1.1through aprotect()ed socket.
The allowlist always wins (FR-4) and nothing is blocked while unlocked. The VPN
is not the enforcement — the child can stop it — so a system revocation
reports a vpn_revoked tamper (FR-17).
Known limitation: VPN consent is manual
Android shows the VPN consent dialog the first time VpnService.prepare() is
called. adb cannot grant it, so the emulator proof below cannot turn the
site filter on. The filter is therefore implemented and unit-tested, not
runtime-verified; it must be consented and checked on a real phone. Do not
claim the VPN path is verified until that is done.
Tests
The wire-format and matching logic is pure JVM code and runs on .27:
ssh 192.168.20.27 'cd ~/android/internet_use_control/android && ./gradlew testDebugUnitTest'
Test on the headless emulator
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 appops set $PKG GET_USAGE_STATS 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
Provisioning the broker on a real device
The lab CA and the per-device MQTT account are not in the repository. Push them instead of rebuilding the app:
PKG=au.audasmedia.webrestrict.kids
adb shell "run-as $PKG sh -c 'cat > files/lab-ca.pem'" < deploy/mosquitto/tls/ca.crt
# then set the broker fields on the status screen, including:
# CA file (lab) = /data/data/$PKG/files/lab-ca.pem
The device topic is restrict/<token>/config, where <token> is the middle
level and also the MQTT account name. AppPrefs.caFile wins over caAsset.