Files
Sam Rolfe b534afb96d Phase 5.2: Android Usage Access telemetry + VpnService site filtering
Continue the Phase 5.1 kids' client (FR-7, FR-14, FR-18).

- Usage Access telemetry: UsageCollector reads the OS per-app foreground
  total for the day, TelemetryStore keeps the highest value per (day, app)
  across restarts, and TelemetryReporter samples every 60 s and publishes
  the list form on restrict/<device>/telemetry. Queued rows republish when
  the broker link returns.
- Device-local VpnService: SiteFilterVpnService routes only a fake resolver
  into its own tunnel, answers NXDOMAIN for a blocked host while locked,
  and forwards everything else upstream through a protected socket. The
  allowlist always wins (FR-4). No router, Pi-hole or DNS change.
- MQTT link: caFile support plus the telemetry topic and payload.
- Status screen: Usage Access, telemetry totals and site-filter state.
- Unit tests for host matching, DNS parse/NXDOMAIN and IPv4/UDP round trip.

No Device Owner, no factory reset, no managed profile. The VPN consent
dialog is manual; adb cannot grant it (recorded in android/README.md).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-13 20:25:19 +11:00

119 lines
5.6 KiB
Markdown

# 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=<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:
```json
{"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.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/<token>/config`, where `<token>` is the middle
level and also the MQTT account name. `AppPrefs.caFile` wins over `caAsset`.