Files
internet_use_control/android
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
..

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.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).

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.