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>
This commit is contained in:
2026-10-13 20:25:19 +11:00
parent e018c3bfa8
commit b534afb96d
19 changed files with 1119 additions and 26 deletions

View File

@@ -1,8 +1,8 @@
# Kids' Android client — Phase 5.1
# Kids' Android client — Phase 5 (5.1 + 5.2)
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`:
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'
@@ -16,27 +16,68 @@ That pulls this repo from Gitea and runs `./gradlew assembleDebug`. It prints
| 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 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`. |
| 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, or a force-stop (FR-17). |
| 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 tamper alert reaches Sam
## How the telemetry path works (FR-7)
The device's broker ACL only lets it write its own `status` topic, so the tamper
report rides there:
`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
{"online":true,"tamper":{"kind":"accessibility_disabled","detail":"the service was destroyed","at":"2026-10-13T08:00:00Z"}}
{"day":"2026-10-13","apps":[{"name":"com.android.chrome","seconds":120}]}
```
`internal/mqtt` parses it, `internal/alert` records a `tamper` alert and delivers
it through Apprise, and `cmd/control` wires the two together.
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
@@ -47,6 +88,7 @@ ssh 192.168.20.27 '~/android/emu.sh install /tmp/webrestrict-pre/android/app/bui
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
@@ -58,6 +100,19 @@ 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'
```
## 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`.