# 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`: ```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=`; 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//config` on the 8883 TLS plane with the device's own credentials; publishes presence and tamper on `restrict//status`, and usage on `restrict//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: ```json {"day":"2026-10-13","apps":[{"name":"com.android.chrome","seconds":120}]} ``` The message goes to `restrict//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.1` through a `protect()`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`: ```bash ssh 192.168.20.27 'cd ~/android/internet_use_control/android && ./gradlew testDebugUnitTest' ``` ## 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 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: ```bash 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//config`, where `` is the middle level and also the MQTT account name. `AppPrefs.caFile` wins over `caAsset`.