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, The 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 FR-4, FR-7, FR-14, FR-16, FR-17, FR-18). A normal Gradle project with its own
**not** on `.13`; it builds on `.27`: wrapper. The toolchain is **not** on `.13`; it builds on `.27`:
```bash ```bash
ssh 192.168.20.27 '~/android/build.sh internet_use_control' 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 | | Piece | File | Job |
|---|---|---| |---|---|---|
| Accessibility Service | `BlockAccessibilityService.kt` | Watches the foreground package (FR-16). | | 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). | | 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). | | 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. | | 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 **No Device Owner. No factory reset. No managed profile.** The child can disable
the client; the enforcement is the tamper alert, not the overlay. 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 `PACKAGE_USAGE_STATS` is a privileged app-op, granted in **Settings → Special app
report rides there: 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 ```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 The message goes to `restrict/<device>/telemetry` over the 8883 TLS plane. It is
it through Apprise, and `cmd/control` wires the two together. 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 ## 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 PKG=au.audasmedia.webrestrict.kids
adb shell appops set $PKG SYSTEM_ALERT_WINDOW allow 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 enabled_accessibility_services $PKG/$PKG.BlockAccessibilityService
adb shell settings put secure accessibility_enabled 1 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. # Show the tamper alert: disable the service.
adb shell settings put secure accessibility_enabled 0 adb shell settings put secure accessibility_enabled 0
adb logcat -d -s WebRestrictTamper 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`.

View File

@@ -12,7 +12,7 @@ android {
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "0.1.0-phase5.1" versionName = "0.1.0-phase5.2"
// The emulator default: a device config with the Settings app blocked. // The emulator default: a device config with the Settings app blocked.
// A real device receives the retained config over MQTT and overwrites // A real device receives the retained config over MQTT and overwrites

View File

@@ -1,17 +1,24 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
Kids' Android client (AGENTS.md §11 Phase 5, FR-16 + FR-17). Kids' Android client (AGENTS.md §11 Phase 5, FR-16 + FR-17 + FR-7 + FR-18).
No Device Owner, no factory reset, no managed profile. The app is an ordinary No Device Owner, no factory reset, no managed profile. The app is an ordinary
app that asks for the Accessibility Service and the overlay permission. The app that asks for the Accessibility Service, the overlay permission, Usage
child can disable it; the enforcement is the tamper alert, not the overlay. Access and — once, manually — VPN consent. The child can disable it; the
enforcement is the tamper alert, not the overlay.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- The full-screen block overlay is a TYPE_APPLICATION_OVERLAY window. --> <!-- The full-screen block overlay is a TYPE_APPLICATION_OVERLAY window. -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- FR-7: read per-app foreground usage. A privileged app-op, granted in
Settings (Usage Access), not at install. -->
<uses-permission
android:name="android.permission.PACKAGE_USAGE_STATS"
tools:ignore="ProtectedPermissions" />
<!-- The watchdog keeps a config connection alive and reports tampering. --> <!-- The watchdog keeps a config connection alive and reports tampering. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
@@ -53,6 +60,19 @@
android:resource="@xml/accessibility_service_config" /> android:resource="@xml/accessibility_service_config" />
</service> </service>
<!-- FR-18: the device-local site filter. It is an ordinary VpnService
with no Device Owner; Android shows its consent dialog once, and no
router, Pi-hole or DNS server is involved. -->
<service
android:name=".SiteFilterVpnService"
android:exported="false"
android:label="@string/vpn_label"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
<!-- FR-17: after a reboot, report that the device is back and reconnect <!-- FR-17: after a reboot, report that the device is back and reconnect
to the broker so the retained config is fetched at once. --> to the broker so the retained config is fetched at once. -->
<receiver <receiver

View File

@@ -43,6 +43,16 @@ class AppPrefs(context: Context) {
get() = prefs.getString(KEY_CA_ASSET, "")!! get() = prefs.getString(KEY_CA_ASSET, "")!!
set(value) = prefs.edit().putString(KEY_CA_ASSET, value).apply() set(value) = prefs.edit().putString(KEY_CA_ASSET, value).apply()
/**
* Optional absolute path to the lab CA file, e.g. "<filesDir>/lab-ca.pem".
* A file is easier to provision than an APK asset: the lab CA is not in the
* repository, so a device is set up by pushing the CA and the broker
* credentials rather than rebuilding the app. A file wins over [caAsset].
*/
var caFile: String
get() = prefs.getString(KEY_CA_FILE, "")!!
set(value) = prefs.edit().putString(KEY_CA_FILE, value).apply()
/** The last config received over MQTT, or the seed. Kept across restarts (FR-14). */ /** The last config received over MQTT, or the seed. Kept across restarts (FR-14). */
var configJson: String? var configJson: String?
get() = prefs.getString(KEY_CONFIG, null) get() = prefs.getString(KEY_CONFIG, null)
@@ -75,6 +85,9 @@ class AppPrefs(context: Context) {
fun statusTopic(): String = deviceTopic.removeSuffix("/config") + "/status" fun statusTopic(): String = deviceTopic.removeSuffix("/config") + "/status"
/** The Phase 3 telemetry topic the device writes its usage roll-up to (FR-7). */
fun telemetryTopic(): String = deviceTopic.removeSuffix("/config") + "/telemetry"
/** The middle topic level, which is also the broker account name. */ /** The middle topic level, which is also the broker account name. */
fun topicToken(): String = fun topicToken(): String =
deviceTopic.removeSuffix("/config").substringAfterLast('/') deviceTopic.removeSuffix("/config").substringAfterLast('/')
@@ -109,6 +122,7 @@ class AppPrefs(context: Context) {
private const val KEY_MQTT_USER = "mqtt_username" private const val KEY_MQTT_USER = "mqtt_username"
private const val KEY_MQTT_PASSWORD = "mqtt_password" private const val KEY_MQTT_PASSWORD = "mqtt_password"
private const val KEY_CA_ASSET = "ca_asset" private const val KEY_CA_ASSET = "ca_asset"
private const val KEY_CA_FILE = "ca_file"
private const val KEY_CONFIG = "config_json" private const val KEY_CONFIG = "config_json"
private const val KEY_HEARTBEAT = "heartbeat_at" private const val KEY_HEARTBEAT = "heartbeat_at"
private const val KEY_HEARTBEAT_BOOT = "heartbeat_boot" private const val KEY_HEARTBEAT_BOOT = "heartbeat_boot"

View File

@@ -0,0 +1,71 @@
package au.audasmedia.webrestrict.kids
/**
* The DNS wire-format pieces the site filter needs (FR-18). It is deliberately
* minimal: the tunnel only carries UDP DNS to the fake resolver, so this parses
* a single question and can build an NXDOMAIN answer. Pure, unit-tested.
*/
object DnsMessage {
/** A parsed question: the lower-case name and the offset just past the question. */
data class Question(val name: String, val end: Int)
/**
* Parses the question section of a DNS query. Returns null for anything
* that is not a single, uncompressed question: a response, a multi-question
* packet, a compression pointer or a truncated/unknown record.
*/
fun parseQuestion(packet: ByteArray, length: Int): Question? {
if (length < HEADER_SIZE) return null
if (u16(packet, 4) != 1) return null // QDCOUNT
if (u16(packet, 2) and 0x8000 != 0) return null // QR: it is a response
var pos = HEADER_SIZE
val name = StringBuilder()
while (pos < length) {
val len = packet[pos].toInt() and 0xFF
if (len == 0) {
pos++
break
}
// A question name is not compressed. A pointer here means the packet
// is not what we expect, so refuse to parse rather than guess.
if (len and 0xC0 == 0xC0) return null
pos++
if (len > 63 || pos + len > length) return null
if (name.isNotEmpty()) name.append('.')
name.append(String(packet, pos, len, Charsets.US_ASCII))
pos += len
}
if (pos + 4 > length) return null // QTYPE + QCLASS
val qname = name.toString().lowercase()
if (qname.isEmpty()) return null
return Question(qname, pos + 4)
}
/**
* Builds an NXDOMAIN answer for [query]. The question is echoed and every
* answer/authority/additional count is zeroed. NXDOMAIN is used instead of
* `0.0.0.0` so a blocked name is a clean "does not exist", not a connection
* to a bogus host.
*/
fun nxdomain(query: ByteArray, questionEnd: Int): ByteArray {
val end = questionEnd.coerceIn(HEADER_SIZE, query.size)
val out = query.copyOf(end)
out[2] = (out[2].toInt() or 0x80).toByte() // QR = 1
out[3] = ((out[3].toInt() and 0xF0) or 0x03).toByte() // RCODE = NXDOMAIN
putU16(out, 6, 0) // ANCOUNT
putU16(out, 8, 0) // NSCOUNT
putU16(out, 10, 0) // ARCOUNT
return out
}
private const val HEADER_SIZE = 12
private fun u16(b: ByteArray, i: Int): Int =
((b[i].toInt() and 0xFF) shl 8) or (b[i + 1].toInt() and 0xFF)
private fun putU16(b: ByteArray, i: Int, v: Int) {
b[i] = ((v ushr 8) and 0xFF).toByte()
b[i + 1] = (v and 0xFF).toByte()
}
}

View File

@@ -0,0 +1,92 @@
package au.audasmedia.webrestrict.kids
/**
* The small slice of the IPv4/UDP wire format the DNS forwarder needs (FR-18).
* The VPN route carries only UDP to the fake resolver, so nothing else arrives
* on the tunnel. Pure, unit-tested.
*/
data class UdpDatagram(
val srcIp: ByteArray,
val dstIp: ByteArray,
val srcPort: Int,
val dstPort: Int,
val payload: ByteArray,
)
object IpPacket {
/** Parses one IPv4/UDP packet, or null for anything else. */
fun parseUdp(packet: ByteArray, length: Int): UdpDatagram? {
if (length < 28) return null
val version = (packet[0].toInt() and 0xF0) ushr 4
if (version != 4) return null
val ihl = (packet[0].toInt() and 0x0F) * 4
if (ihl < 20 || ihl + 8 > length) return null
if ((packet[9].toInt() and 0xFF) != 17) return null // protocol: UDP
val udpLen = u16(packet, ihl + 4)
val payloadStart = ihl + 8
val payloadEnd = minOf(length, ihl + udpLen)
if (payloadEnd <= payloadStart) return null
return UdpDatagram(
srcIp = packet.copyOfRange(12, 16),
dstIp = packet.copyOfRange(16, 20),
srcPort = u16(packet, ihl),
dstPort = u16(packet, ihl + 2),
payload = packet.copyOfRange(payloadStart, payloadEnd),
)
}
/**
* Builds one IPv4/UDP packet. The UDP checksum is left as 0, which IPv4
* permits ("no checksum"); the IP header checksum is computed.
*/
fun buildUdp(
srcIp: ByteArray,
dstIp: ByteArray,
srcPort: Int,
dstPort: Int,
payload: ByteArray,
): ByteArray {
val total = 20 + 8 + payload.size
val out = ByteArray(total)
out[0] = 0x45
putU16(out, 2, total)
putU16(out, 4, 0)
putU16(out, 6, 0)
out[8] = 64 // TTL
out[9] = 17 // UDP
System.arraycopy(srcIp, 0, out, 12, 4)
System.arraycopy(dstIp, 0, out, 16, 4)
putU16(out, 10, checksum(out, 0, 20))
putU16(out, 20, srcPort)
putU16(out, 22, dstPort)
putU16(out, 24, 8 + payload.size)
putU16(out, 26, 0)
System.arraycopy(payload, 0, out, 28, payload.size)
return out
}
fun u16(b: ByteArray, i: Int): Int =
((b[i].toInt() and 0xFF) shl 8) or (b[i + 1].toInt() and 0xFF)
fun putU16(b: ByteArray, i: Int, v: Int) {
b[i] = ((v ushr 8) and 0xFF).toByte()
b[i + 1] = (v and 0xFF).toByte()
}
/** The standard internet checksum over [len] bytes starting at [offset]. */
fun checksum(b: ByteArray, offset: Int, len: Int): Int {
var sum = 0
var i = offset
val end = offset + len
while (i + 1 < end) {
sum += u16(b, i)
i += 2
}
if (i < end) sum += (b[i].toInt() and 0xFF) shl 8
while (sum ushr 16 != 0) sum = (sum and 0xFFFF) + (sum ushr 16)
return sum.inv() and 0xFFFF
}
}

View File

@@ -14,6 +14,8 @@ class KidsApp : Application() {
ConfigStore(this).seedIfEmpty() ConfigStore(this).seedIfEmpty()
TamperReporter.detectForceStop(this) TamperReporter.detectForceStop(this)
MqttController.start(this) MqttController.start(this)
// FR-7: sample Usage Access and publish the day's per-app totals.
TelemetryReporter.start(this)
} catch (e: Exception) { } catch (e: Exception) {
// A failure here must never turn on the crash dialog for a kid. // A failure here must never turn on the crash dialog for a kid.
Log.e(TAG, "startup failed: ${e.message}") Log.e(TAG, "startup failed: ${e.message}")

View File

@@ -4,6 +4,7 @@ import android.content.ComponentName
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.net.Uri import android.net.Uri
import android.net.VpnService
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.text.InputType import android.text.InputType
@@ -45,6 +46,14 @@ class MainActivity : AppCompatActivity() {
refresh() refresh()
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_VPN && resultCode == RESULT_OK) {
SiteFilterVpnService.start(this)
refresh()
}
}
private fun refresh() { private fun refresh() {
container.removeAllViews() container.removeAllViews()
val prefs = AppPrefs(this) val prefs = AppPrefs(this)
@@ -77,6 +86,25 @@ class MainActivity : AppCompatActivity() {
else "${prefs.lastTamperKind} at ${time(tamperAt)} (${prefs.lastTamperDetail})", else "${prefs.lastTamperKind} at ${time(tamperAt)} (${prefs.lastTamperDetail})",
) )
section("Usage and site filter")
val usageAccess = UsageCollector.hasUsageAccess(this)
row("Usage Access", if (usageAccess) "granted" else "NOT GRANTED — no telemetry")
val telemetryStore = TelemetryStore(this)
val today = UsageCollector.dayKey(System.currentTimeMillis())
val usageRows = telemetryStore.day(today)
row(
"Telemetry today",
if (usageRows.isEmpty()) "none yet"
else "${usageRows.size} apps, ${usageRows.values.sum()} s",
)
row(
"Last published",
if (telemetryStore.lastPublishedAt() <= 0) "never" else time(telemetryStore.lastPublishedAt()),
)
row("Site filter (VPN)", if (SiteFilterVpnService.isRunning) "running" else "stopped")
row("Blocked sites", config.block.joinToString(", ").ifBlank { "none" })
row("Allowed sites", config.allow.joinToString(", ").ifBlank { "none" })
section("Actions") section("Actions")
button("Open Accessibility settings") { button("Open Accessibility settings") {
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
@@ -89,6 +117,20 @@ class MainActivity : AppCompatActivity() {
), ),
) )
} }
button("Grant Usage Access") {
startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS))
}
button("Sample telemetry now") {
TelemetryReporter.sampleNow()
}
button(if (SiteFilterVpnService.isRunning) "Stop the site filter" else "Enable the site filter") {
if (SiteFilterVpnService.isRunning) {
SiteFilterVpnService.stop(this)
refresh()
} else {
requestVpn()
}
}
button("Reload the local default config") { button("Reload the local default config") {
ConfigStore(this).save(DeviceConfig.default()) ConfigStore(this).save(DeviceConfig.default())
refresh() refresh()
@@ -104,10 +146,25 @@ class MainActivity : AppCompatActivity() {
it.toIntOrNull()?.let { port -> prefs.brokerPort = port } it.toIntOrNull()?.let { port -> prefs.brokerPort = port }
} }
field("Device config topic", prefs.deviceTopic) { prefs.deviceTopic = it } field("Device config topic", prefs.deviceTopic) { prefs.deviceTopic = it }
field("CA file (lab)", prefs.caFile) { prefs.caFile = it; MqttController.stop(); MqttController.start(this) }
field("MQTT username", prefs.mqttUsername) { prefs.mqttUsername = it } field("MQTT username", prefs.mqttUsername) { prefs.mqttUsername = it }
field("MQTT password", prefs.mqttPassword) { prefs.mqttPassword = it; MqttController.stop(); MqttController.start(this) } field("MQTT password", prefs.mqttPassword) { prefs.mqttPassword = it; MqttController.stop(); MqttController.start(this) }
} }
/**
* Asks for VPN consent if it has never been granted, then starts the
* filter. The system dialog is the one step adb cannot grant.
*/
private fun requestVpn() {
val consent = VpnService.prepare(this)
if (consent != null) {
startActivityForResult(consent, REQUEST_VPN)
} else {
SiteFilterVpnService.start(this)
refresh()
}
}
private fun isAccessibilityEnabled(): Boolean { private fun isAccessibilityEnabled(): Boolean {
val expected = ComponentName(this, BlockAccessibilityService::class.java) val expected = ComponentName(this, BlockAccessibilityService::class.java)
val enabled = Settings.Secure.getString( val enabled = Settings.Secure.getString(
@@ -186,4 +243,8 @@ class MainActivity : AppCompatActivity() {
private fun dp(value: Int): Int = private fun dp(value: Int): Int =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), resources.displayMetrics).toInt() TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), resources.displayMetrics).toInt()
companion object {
private const val REQUEST_VPN = 1001
}
} }

View File

@@ -13,6 +13,7 @@ import org.eclipse.paho.client.mqttv3.MqttCallbackExtended
import org.eclipse.paho.client.mqttv3.MqttConnectOptions import org.eclipse.paho.client.mqttv3.MqttConnectOptions
import org.eclipse.paho.client.mqttv3.MqttMessage import org.eclipse.paho.client.mqttv3.MqttMessage
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence
import java.io.File
import java.io.InputStream import java.io.InputStream
import java.security.KeyStore import java.security.KeyStore
import java.security.cert.CertificateFactory import java.security.cert.CertificateFactory
@@ -60,6 +61,9 @@ object MqttController {
@Volatile @Volatile
private var configListener: ((DeviceConfig) -> Unit)? = null private var configListener: ((DeviceConfig) -> Unit)? = null
@Volatile
private var connectedListener: (() -> Unit)? = null
fun isConnected(): Boolean = connected fun isConnected(): Boolean = connected
fun lastError(): String? = lastError fun lastError(): String? = lastError
@@ -69,6 +73,15 @@ object MqttController {
configListener = listener configListener = listener
} }
/**
* Registers a callback that runs whenever the link (re)connects, so queued
* local state such as the telemetry roll-up can be published at once
* (FR-7, FR-14). It runs on the MQTT thread; do not block it.
*/
fun setOnConnected(listener: () -> Unit) {
connectedListener = listener
}
/** Starts the link once. Safe to call from Application, Activity and the service. */ /** Starts the link once. Safe to call from Application, Activity and the service. */
fun start(context: Context) { fun start(context: Context) {
if (client != null) return if (client != null) return
@@ -127,6 +140,7 @@ object MqttController {
Log.i(TAG, "mqtt connected to ${prefs.brokerHost}:${prefs.brokerPort}") Log.i(TAG, "mqtt connected to ${prefs.brokerHost}:${prefs.brokerPort}")
subscribe(c, prefs) subscribe(c, prefs)
publishStatus(app, null) publishStatus(app, null)
connectedListener?.invoke()
} }
override fun onFailure(token: IMqttToken?, exception: Throwable?) { override fun onFailure(token: IMqttToken?, exception: Throwable?) {
@@ -160,6 +174,7 @@ object MqttController {
Log.i(TAG, "mqtt connectComplete reconnect=$reconnect") Log.i(TAG, "mqtt connectComplete reconnect=$reconnect")
subscribe(c, prefs) subscribe(c, prefs)
publishStatus(app, null) publishStatus(app, null)
connectedListener?.invoke()
} }
override fun connectionLost(cause: Throwable?) { override fun connectionLost(cause: Throwable?) {
@@ -197,6 +212,24 @@ object MqttController {
publish(prefs.statusTopic(), payload, retained = true) publish(prefs.statusTopic(), payload, retained = true)
} }
/**
* Publishes the day's per-app usage roll-up on the Phase 3 telemetry topic
* (FR-7). The payload is the list form the Control Service accepts:
* `{"day":"YYYY-MM-DD","apps":[{"name":"pkg","seconds":N}]}`. The
* message is not retained: it is a report, not a desired state.
*/
fun publishTelemetry(context: Context, day: String, apps: Map<String, Int>) {
if (apps.isEmpty()) return
val payload = TelemetryPayload(
day = day,
apps = apps.entries
.filter { it.key.isNotBlank() && it.value > 0 }
.sortedBy { it.key }
.map { UsageEntry(it.key, it.value) },
)
publish(AppPrefs(context).telemetryTopic(), payload, retained = false)
}
/** Publishes the tamper report on the same status topic (FR-17). */ /** Publishes the tamper report on the same status topic (FR-17). */
fun publishTamper(context: Context, kind: String, detail: String) { fun publishTamper(context: Context, kind: String, detail: String) {
val prefs = AppPrefs(context) val prefs = AppPrefs(context)
@@ -235,14 +268,25 @@ object MqttController {
} }
/** /**
* Builds the TLS socket factory. With no CA asset configured the platform * Builds the TLS socket factory. With no CA configured the platform trust
* trust store is used, which is what a public broker would need. A lab CA is * store is used, which is what a public broker would need. A lab CA is
* loaded from assets so the broker's own certificate verifies. * loaded from a file ([AppPrefs.caFile]) or an asset ([AppPrefs.caAsset]) so
* the broker's own certificate verifies.
*/ */
private fun tlsSocketFactory(context: Context, prefs: AppPrefs): SSLSocketFactory? { private fun tlsSocketFactory(context: Context, prefs: AppPrefs): SSLSocketFactory? {
if (prefs.caAsset.isBlank()) return null val certs = try {
when {
prefs.caFile.isNotBlank() ->
File(prefs.caFile).inputStream().use(::readCertificates)
prefs.caAsset.isNotBlank() ->
context.assets.open(prefs.caAsset).use(::readCertificates)
else -> return null
}
} catch (e: Exception) {
Log.w(TAG, "cannot load CA (file=${prefs.caFile} asset=${prefs.caAsset}): ${e.message}")
return null
}
return try { return try {
val certs = context.assets.open(prefs.caAsset).use(::readCertificates)
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply {
load(null, null) load(null, null)
certs.forEachIndexed { i, cert -> setCertificateEntry("ca$i", cert) } certs.forEachIndexed { i, cert -> setCertificateEntry("ca$i", cert) }
@@ -251,7 +295,7 @@ object MqttController {
tmf.init(keyStore) tmf.init(keyStore)
SSLContext.getInstance("TLS").apply { init(null, tmf.trustManagers, null) }.socketFactory SSLContext.getInstance("TLS").apply { init(null, tmf.trustManagers, null) }.socketFactory
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "cannot load CA asset ${prefs.caAsset}: ${e.message}") Log.w(TAG, "cannot build TLS context: ${e.message}")
null null
} }
} }
@@ -266,6 +310,10 @@ object MqttController {
private data class TamperPayload(val kind: String, val detail: String?, val at: String) private data class TamperPayload(val kind: String, val detail: String?, val at: String)
private data class UsageEntry(val name: String, val seconds: Int)
private data class TelemetryPayload(val day: String, val apps: List<UsageEntry>)
private data class StatusPayload( private data class StatusPayload(
val online: Boolean, val online: Boolean,
val app: String? = null, val app: String? = null,

View File

@@ -0,0 +1,50 @@
package au.audasmedia.webrestrict.kids
/**
* Hostname filtering for the device-local VpnService (FR-18).
*
* The Phase 3 config payload carries the block and allow values as one flat
* list ([DeviceConfig.block] / [DeviceConfig.allow]); the list kind ("app" or
* "site") is not on the wire. A value is matched here as a hostname, by exact
* name or by subdomain, so a site entry such as `youtube.com` covers
* `www.youtube.com` and an app identifier such as `com.android.chrome` never
* matches a real hostname in practice.
*
* A host is blocked only while the device is locked, and an allowlist entry
* always wins, which is FR-4 ("Spotify, school sites"). The client never
* widens or narrows the rules itself (AGENTS.md §10 rule 7).
*
* Pure logic: unit-tested without an emulator.
*/
object SiteFilter {
/** The first block/allow value that matches [host], or null. */
fun matchHost(host: String, entries: List<String>): String? {
val h = normalizeHost(host) ?: return null
for (entry in entries) {
val v = normalizeHost(entry) ?: continue
if (h == v || h.endsWith("." + v)) return entry
}
return null
}
/**
* A usable hostname: non-blank, no whitespace or path separator, lower case
* and without a trailing root dot. Returns null when the value cannot be a
* hostname, so a stray credential or comment can never match.
*/
fun normalizeHost(value: String?): String? {
if (value.isNullOrBlank()) return null
val v = value.trim().trim('.').lowercase()
if (v.isEmpty()) return null
if (v.any { it.isWhitespace() || it == '/' || it == '\\' }) return null
return v
}
/** Whether [host] must be refused while the current config is applied. */
fun isBlocked(host: String, config: DeviceConfig): Boolean {
if (!config.locked) return false
if (matchHost(host, config.block) == null) return false
return matchHost(host, config.allow) == null
}
}

View File

@@ -0,0 +1,204 @@
package au.audasmedia.webrestrict.kids
import android.content.Context
import android.content.Intent
import android.net.VpnService
import android.os.ParcelFileDescriptor
import android.util.Log
import java.io.FileInputStream
import java.io.FileOutputStream
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.util.concurrent.atomic.AtomicBoolean
/**
* Device-local hostname filtering (FR-18). No router, no Pi-hole, no DNS-server
* change: the app starts an ordinary Android `VpnService`, routes only a fake
* resolver address into its own tunnel, and answers DNS itself.
*
* A query for a blocked host gets NXDOMAIN. Every other query is forwarded to
* an upstream resolver through a protected socket, so only DNS is touched and
* normal traffic never enters the tunnel. The allowlist always wins (FR-4), and
* nothing is blocked while the device is unlocked.
*
* Consent cannot be granted by adb: [VpnService.prepare] shows a system dialog.
* That part is manual on a real device (see README).
*/
class SiteFilterVpnService : VpnService() {
private val running = AtomicBoolean(false)
private var tun: ParcelFileDescriptor? = null
private var loop: Thread? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
stopFilter()
return START_NOT_STICKY
}
startFilter()
return START_STICKY
}
private fun startFilter() {
if (running.get()) return
val builder = Builder()
.setSession("WebRestrict site filter")
.addAddress(IFACE_ADDRESS, 32)
.addDnsServer(FAKE_DNS)
.addRoute(FAKE_DNS, 32)
.setMtu(1500)
val pfd = try {
builder.establish()
} catch (e: Exception) {
Log.w(TAG, "cannot establish the tunnel: ${e.message}")
null
}
if (pfd == null) {
Log.w(TAG, "VPN consent missing or refused; not filtering")
return
}
tun = pfd
running.set(true)
isRunning = true
MqttController.publishStatus(this, null)
loop = Thread({ readLoop(pfd) }, "webrestrict-vpn").apply {
isDaemon = true
start()
}
Log.i(TAG, "site filter started iface=$IFACE_ADDRESS dns=$FAKE_DNS upstream=$UPSTREAM_DNS")
}
private fun readLoop(pfd: ParcelFileDescriptor) {
val input = FileInputStream(pfd.fileDescriptor)
val output = FileOutputStream(pfd.fileDescriptor)
val buffer = ByteArray(32767)
val upstream = DatagramSocket()
try {
protect(upstream)
} catch (e: Exception) {
Log.w(TAG, "cannot protect the upstream socket: ${e.message}")
}
upstream.soTimeout = UPSTREAM_TIMEOUT_MS
try {
while (running.get()) {
val n = try {
input.read(buffer)
} catch (e: Exception) {
if (running.get()) Log.w(TAG, "tunnel read: ${e.message}")
break
}
if (n <= 0) continue
handle(buffer, n, output, upstream)
}
} finally {
try {
upstream.close()
} catch (_: Exception) {
}
try {
input.close()
} catch (_: Exception) {
}
try {
output.close()
} catch (_: Exception) {
}
}
}
private fun handle(
packet: ByteArray,
length: Int,
output: FileOutputStream,
upstream: DatagramSocket,
) {
val udp = IpPacket.parseUdp(packet, length) ?: return
if (udp.dstPort != DNS_PORT) return
val question = DnsMessage.parseQuestion(udp.payload, udp.payload.size) ?: return
val config = ConfigStore(this).load()
val blocked = SiteFilter.isBlocked(question.name, config)
Log.d(TAG, "dns ${question.name} blocked=$blocked locked=${config.locked}")
val response = if (blocked) {
DnsMessage.nxdomain(udp.payload, question.end)
} else {
forward(udp.payload, upstream) ?: return
}
// Swap source and destination: reply to the client that asked.
val reply = IpPacket.buildUdp(udp.dstIp, udp.srcIp, udp.dstPort, udp.srcPort, response)
try {
output.write(reply)
} catch (e: Exception) {
Log.w(TAG, "tunnel write: ${e.message}")
}
}
/** Sends the raw query upstream and returns the raw response, or null. */
private fun forward(query: ByteArray, socket: DatagramSocket): ByteArray? {
return try {
val upstream = InetAddress.getByName(UPSTREAM_DNS)
socket.send(DatagramPacket(query, query.size, upstream, DNS_PORT))
val buf = ByteArray(4096)
val response = DatagramPacket(buf, buf.size)
socket.receive(response)
buf.copyOf(response.length)
} catch (e: Exception) {
Log.w(TAG, "upstream DNS failed: ${e.message}")
null
}
}
private fun stopFilter() {
if (!running.getAndSet(false)) return
isRunning = false
try {
tun?.close()
} catch (e: Exception) {
Log.w(TAG, "cannot close the tunnel: ${e.message}")
}
tun = null
loop = null
Log.i(TAG, "site filter stopped")
}
override fun onRevoke() {
TamperReporter.report(this, "vpn_revoked", "the system revoked the VPN or another VPN took over")
stopFilter()
super.onRevoke()
}
override fun onDestroy() {
stopFilter()
super.onDestroy()
}
companion object {
const val ACTION_START = "au.audasmedia.webrestrict.kids.action.START_VPN"
const val ACTION_STOP = "au.audasmedia.webrestrict.kids.action.STOP_VPN"
private const val TAG = "WebRestrictVpn"
private const val IFACE_ADDRESS = "10.111.222.1"
private const val FAKE_DNS = "10.111.222.2"
private const val UPSTREAM_DNS = "1.1.1.1"
private const val DNS_PORT = 53
private const val UPSTREAM_TIMEOUT_MS = 4000
/** Whether the tunnel is established, for the status screen. */
@Volatile
var isRunning: Boolean = false
fun start(context: Context) {
val intent = Intent(context, SiteFilterVpnService::class.java).setAction(ACTION_START)
context.startService(intent)
}
fun stop(context: Context) {
val intent = Intent(context, SiteFilterVpnService::class.java).setAction(ACTION_STOP)
context.startService(intent)
}
}
}

View File

@@ -0,0 +1,87 @@
package au.audasmedia.webrestrict.kids
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import java.util.concurrent.Executors
/**
* Samples Usage Access on a timer and publishes the day's per-app totals to the
* Phase 3 telemetry topic (FR-7).
*
* The sample runs off the main thread. The local roll-up in [TelemetryStore] is
* written first, so an offline device keeps its usage and republishes it the
* moment the broker link returns (FR-14). A device without Usage Access is
* simply silent: it does not crash and does not publish empty rows.
*/
object TelemetryReporter {
private const val TAG = "WebRestrictTelemetry"
private const val SAMPLE_MS = 60_000L
private val main = Handler(Looper.getMainLooper())
private val executor = Executors.newSingleThreadExecutor { r ->
Thread(r, "webrestrict-telemetry").apply { isDaemon = true }
}
@Volatile
private var app: Context? = null
@Volatile
private var started = false
private val tick = object : Runnable {
override fun run() {
sampleAsync()
main.postDelayed(this, SAMPLE_MS)
}
}
/** Starts the sampler once. Safe to call from Application and Activity. */
fun start(context: Context) {
app = context.applicationContext
if (started) return
started = true
MqttController.setOnConnected { publishLatest() }
main.post(tick)
}
/** Reads and publishes immediately, for the status screen. */
fun sampleNow() {
sampleAsync()
}
private fun sampleAsync() {
val ctx = app ?: return
executor.execute {
try {
if (!UsageCollector.hasUsageAccess(ctx)) return@execute
val now = System.currentTimeMillis()
val day = UsageCollector.dayKey(now)
val sample = UsageCollector.sample(ctx, now)
if (sample.isEmpty()) return@execute
val merged = TelemetryStore(ctx).record(day, sample)
publish(ctx, day, merged)
} catch (e: Exception) {
Log.w(TAG, "telemetry sample failed: ${e.message}")
}
}
}
/** Republishes the last roll-up, used when the broker link comes back. */
private fun publishLatest() {
val ctx = app ?: return
executor.execute {
val now = System.currentTimeMillis()
val day = UsageCollector.dayKey(now)
val rows = TelemetryStore(ctx).day(day)
if (rows.isNotEmpty()) publish(ctx, day, rows)
}
}
private fun publish(ctx: Context, day: String, rows: Map<String, Int>) {
MqttController.publishTelemetry(ctx, day, rows)
TelemetryStore(ctx).markPublished(System.currentTimeMillis())
Log.i(TAG, "telemetry day=$day apps=${rows.size} sample=\"${rows.entries.take(3).joinToString { "${it.key}=${it.value}s" }}\"")
}
}

View File

@@ -0,0 +1,93 @@
package au.audasmedia.webrestrict.kids
import android.content.Context
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import android.util.Log
/**
* Local daily roll-up of Usage Access samples. It survives a restart and a
* broker outage (FR-14): the OS supplies a cumulative daily total, and this
* store keeps the highest total seen per (day, package), so a stats reset or a
* restart can never lower a total that was already published.
*
* The format written is the list form the Phase 3 telemetry topic accepts:
* `{"day":"YYYY-MM-DD","apps":[{"name":"pkg","seconds":N}]}`.
*/
class TelemetryStore(context: Context) {
private val prefs = context.applicationContext
.getSharedPreferences(FILE, Context.MODE_PRIVATE)
private val gson = Gson()
/** day -> package -> seconds. */
private var days: MutableMap<String, MutableMap<String, Int>> = load()
/** Merges one sample into the day and returns the day's full roll-up. */
fun record(day: String, samples: Map<String, Int>): Map<String, Int> {
val merged = merge(days[day] ?: emptyMap(), samples)
val row = merged.toMutableMap()
days[day] = row
val kept = prune(days, MAX_DAYS)
days = HashMap<String, MutableMap<String, Int>>().apply {
for ((k, v) in kept) put(k, v.toMutableMap())
}
save()
return row
}
/** The stored roll-up for one day. */
fun day(day: String): Map<String, Int> = days[day]?.toMap() ?: emptyMap()
fun lastPublishedAt(): Long = prefs.getLong(KEY_PUBLISHED, 0L)
fun markPublished(at: Long) {
prefs.edit().putLong(KEY_PUBLISHED, at).apply()
}
private fun load(): MutableMap<String, MutableMap<String, Int>> {
val json = prefs.getString(KEY_DAYS, null) ?: return mutableMapOf()
return try {
val type = object : TypeToken<MutableMap<String, MutableMap<String, Int>>>() {}.type
val parsed: MutableMap<String, MutableMap<String, Int>>? = gson.fromJson(json, type)
parsed ?: mutableMapOf()
} catch (e: Exception) {
Log.w(TAG, "telemetry store is unreadable, starting empty: ${e.message}")
mutableMapOf()
}
}
private fun save() {
prefs.edit().putString(KEY_DAYS, gson.toJson(days)).apply()
}
companion object {
private const val TAG = "WebRestrictTelemetry"
private const val FILE = "webrestrict_telemetry"
private const val KEY_DAYS = "days"
private const val KEY_PUBLISHED = "last_published_at"
private const val MAX_DAYS = 14
/**
* Merges [samples] into [existing], keeping the higher total per
* package. Pure so it is unit-tested without Android.
*/
fun merge(existing: Map<String, Int>, samples: Map<String, Int>): Map<String, Int> {
val out = HashMap<String, Int>(existing)
for ((pkg, seconds) in samples) {
if (pkg.isBlank() || seconds <= 0) continue
if (seconds > (out[pkg] ?: 0)) out[pkg] = seconds
}
return out
}
/** Keeps the [max] most recent days. Pure so it is unit-tested. */
fun prune(
days: Map<String, Map<String, Int>>,
max: Int,
): Map<String, Map<String, Int>> =
days.entries
.sortedBy { it.key }
.takeLast(max)
.associate { it.key to it.value }
}
}

View File

@@ -0,0 +1,87 @@
package au.audasmedia.webrestrict.kids
import android.app.AppOpsManager
import android.app.usage.UsageStatsManager
import android.content.Context
import android.os.Build
import android.os.Process
import android.util.Log
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
/**
* Reads the OS's per-app foreground total for one day (FR-7). It needs Usage
* Access (`PACKAGE_USAGE_STATS` / the `GET_USAGE_STATS` app-op), which the
* system grants from Settings, not at install.
*
* The query is cumulative for the day, so a restart cannot lose the earlier
* total; [TelemetryStore] keeps the highest value it has seen anyway.
*/
object UsageCollector {
private const val TAG = "WebRestrictUsage"
/** Foreground seconds per package for the day that contains [nowMillis]. */
fun sample(context: Context, nowMillis: Long): Map<String, Int> {
val usm = context.getSystemService(Context.USAGE_STATS_SERVICE) as? UsageStatsManager
?: return emptyMap()
val start = startOfDay(nowMillis)
val stats = try {
usm.queryUsageStats(UsageStatsManager.INTERVAL_DAILY, start, nowMillis)
} catch (e: Exception) {
Log.w(TAG, "usage query failed: ${e.message}")
return emptyMap()
} ?: return emptyMap()
val out = HashMap<String, Int>()
for (s in stats) {
val pkg = s.packageName ?: continue
// The enforcer itself is not the child's screen time.
if (pkg == context.packageName) continue
val seconds = (s.totalTimeInForeground / 1000L).toInt()
if (seconds <= 0) continue
out[pkg] = (out[pkg] ?: 0) + seconds
}
return out
}
/** The YYYY-MM-DD key the Control Service expects for a report. */
fun dayKey(nowMillis: Long): String =
SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date(nowMillis))
/** Whether Usage Access is granted. */
fun hasUsageAccess(context: Context): Boolean {
val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as? AppOpsManager
?: return false
return try {
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
appOps.unsafeCheckOpNoThrow(
AppOpsManager.OPSTR_GET_USAGE_STATS,
Process.myUid(),
context.packageName,
)
} else {
@Suppress("DEPRECATION")
appOps.checkOpNoThrow(
AppOpsManager.OPSTR_GET_USAGE_STATS,
Process.myUid(),
context.packageName,
)
}
mode == AppOpsManager.MODE_ALLOWED
} catch (e: Exception) {
Log.w(TAG, "cannot read the usage app-op: ${e.message}")
false
}
}
fun startOfDay(nowMillis: Long): Long =
Calendar.getInstance().apply {
timeInMillis = nowMillis
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}

View File

@@ -5,4 +5,5 @@
<string name="accessibility_description">Screen Limits uses this service to see which app is in the <string name="accessibility_description">Screen Limits uses this service to see which app is in the
foreground, so it can block an app on the family blocklist while a limit is active. It does foreground, so it can block an app on the family blocklist while a limit is active. It does
not read the screen or your typing.</string> not read the screen or your typing.</string>
<string name="vpn_label">Screen Limits site filter</string>
</resources> </resources>

View File

@@ -0,0 +1,68 @@
package au.audasmedia.webrestrict.kids
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
import java.io.ByteArrayOutputStream
/** DNS parsing and NXDOMAIN building for the site filter (FR-18). Pure tests. */
class DnsMessageTest {
@Test
fun `parses the question of a standard query`() {
val query = query("www.youtube.com")
val question = DnsMessage.parseQuestion(query, query.size)
assertNotNull(question)
assertEquals("www.youtube.com", question!!.name)
// 12 header + labels + root + qtype/qclass
assertEquals(query.size, question.end)
}
@Test
fun `lower-cases the question and rejects a response`() {
val query = query("WWW.YouTube.COM")
assertEquals("www.youtube.com", DnsMessage.parseQuestion(query, query.size)!!.name)
val response = query.copyOf()
response[2] = (response[2].toInt() or 0x80).toByte()
assertNull(DnsMessage.parseQuestion(response, response.size))
}
@Test
fun `rejects a malformed packet`() {
assertNull(DnsMessage.parseQuestion(ByteArray(4), 4))
assertNull(DnsMessage.parseQuestion(query("youtube.com"), 14))
}
@Test
fun `nxdomain echoes the question and sets the flags`() {
val query = query("youtube.com")
val question = DnsMessage.parseQuestion(query, query.size)!!
val answer = DnsMessage.nxdomain(query, question.end)
assertEquals(question.end, answer.size)
assertEquals(query[0], answer[0])
assertEquals(query[1], answer[1])
// QR = 1 and RCODE = 3.
assertEquals(0x80, answer[2].toInt() and 0x80)
assertEquals(0x03, answer[3].toInt() and 0x0F)
// No answer, authority or additional records.
assertEquals(0, IpPacket.u16(answer, 6))
assertEquals(0, IpPacket.u16(answer, 8))
assertEquals(0, IpPacket.u16(answer, 10))
}
/** Builds one standard A query for [name]. */
private fun query(name: String): ByteArray {
val out = ByteArrayOutputStream()
out.write(byteArrayOf(0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0))
for (label in name.split('.')) {
out.write(label.length)
out.write(label.toByteArray(Charsets.US_ASCII))
}
out.write(0)
out.write(byteArrayOf(0x00, 0x01, 0x00, 0x01)) // QTYPE=A, QCLASS=IN
return out.toByteArray()
}
}

View File

@@ -0,0 +1,43 @@
package au.audasmedia.webrestrict.kids
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** IPv4/UDP parsing and building for the DNS forwarder (FR-18). Pure tests. */
class IpPacketTest {
private val clientIp = byteArrayOf(10, 111, 222, 1)
private val dnsIp = byteArrayOf(10, 111, 222, 2)
@Test
fun `builds a packet that parses back`() {
val payload = byteArrayOf(1, 2, 3, 4, 5)
val packet = IpPacket.buildUdp(clientIp, dnsIp, 40000, 53, payload)
val udp = IpPacket.parseUdp(packet, packet.size)
assertEquals(17, packet[9].toInt() and 0xFF)
assertEquals(20 + 8 + payload.size, IpPacket.u16(packet, 2))
assertEquals(0, IpPacket.checksum(packet, 0, 20))
assertEquals(40000, udp!!.srcPort)
assertEquals(53, udp.dstPort)
assertArrayEquals(clientIp, udp.srcIp)
assertArrayEquals(dnsIp, udp.dstIp)
assertArrayEquals(payload, udp.payload)
}
@Test
fun `ignores a non UDP packet`() {
val packet = IpPacket.buildUdp(clientIp, dnsIp, 40000, 53, byteArrayOf(1))
packet[9] = 6 // TCP
assertNull(IpPacket.parseUdp(packet, packet.size))
}
@Test
fun `ignores a truncated packet`() {
assertNull(IpPacket.parseUdp(ByteArray(10), 10))
val packet = IpPacket.buildUdp(clientIp, dnsIp, 40000, 53, byteArrayOf(1, 2, 3))
assertNull(IpPacket.parseUdp(packet, 10))
}
}

View File

@@ -0,0 +1,61 @@
package au.audasmedia.webrestrict.kids
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Hostname matching for the site filter (FR-18, FR-4). No emulator needed. */
class SiteFilterTest {
@Test
fun `matches an exact host and a subdomain`() {
assertEquals("youtube.com", SiteFilter.matchHost("youtube.com", listOf("youtube.com")))
assertEquals("youtube.com", SiteFilter.matchHost("www.youtube.com", listOf("youtube.com")))
assertNull(SiteFilter.matchHost("notyoutube.com", listOf("youtube.com")))
}
@Test
fun `matching ignores case and a trailing dot`() {
assertEquals("YouTube.com", SiteFilter.matchHost("WWW.youtube.com.", listOf("YouTube.com")))
}
@Test
fun `blocks a listed host while locked`() {
val config = DeviceConfig(locked = true, block = listOf("youtube.com"))
assertTrue(SiteFilter.isBlocked("www.youtube.com", config))
assertFalse(SiteFilter.isBlocked("example.com", config))
}
@Test
fun `does not block while unlocked`() {
val config = DeviceConfig(locked = false, block = listOf("youtube.com"))
assertFalse(SiteFilter.isBlocked("www.youtube.com", config))
}
@Test
fun `the allowlist wins during a block`() {
val config = DeviceConfig(
locked = true,
block = listOf("youtube.com", "school.edu"),
allow = listOf("school.edu"),
)
assertTrue(SiteFilter.isBlocked("www.youtube.com", config))
assertFalse(SiteFilter.isBlocked("portal.school.edu", config))
}
@Test
fun `an app identifier never matches a real host`() {
val config = DeviceConfig(locked = true, block = listOf("com.google.android.youtube"))
assertFalse(SiteFilter.isBlocked("www.youtube.com", config))
}
@Test
fun `a blank or path-like entry is ignored`() {
assertNull(SiteFilter.normalizeHost(""))
assertNull(SiteFilter.normalizeHost(" "))
assertNull(SiteFilter.normalizeHost("http://youtube.com/watch"))
assertNull(SiteFilter.normalizeHost("a b.com"))
}
}

View File

@@ -0,0 +1,36 @@
package au.audasmedia.webrestrict.kids
import org.junit.Assert.assertEquals
import org.junit.Test
/** The pure part of the local telemetry roll-up (FR-7, FR-14). */
class TelemetryStoreTest {
@Test
fun `merge keeps the higher total per package`() {
val existing = mapOf("com.a" to 100, "com.b" to 5)
val samples = mapOf("com.a" to 40, "com.b" to 9, "com.c" to 3)
val merged = TelemetryStore.merge(existing, samples)
assertEquals(100, merged["com.a"])
assertEquals(9, merged["com.b"])
assertEquals(3, merged["com.c"])
}
@Test
fun `merge ignores blanks and negatives`() {
val merged = TelemetryStore.merge(emptyMap(), mapOf("" to 10, "com.a" to -5, "com.b" to 0))
assertEquals(emptyMap<String, Int>(), merged)
}
@Test
fun `prune keeps the most recent days and drops the oldest`() {
val days = linkedMapOf(
"2026-10-01" to mapOf("com.a" to 1),
"2026-10-02" to mapOf("com.a" to 2),
"2026-10-03" to mapOf("com.a" to 3),
)
val pruned = TelemetryStore.prune(days, 2)
assertEquals(setOf("2026-10-02", "2026-10-03"), pruned.keys)
}
}