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:
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }}\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user