Phase 5.2: Android Usage Access telemetry + VpnService site filtering

Continue the Phase 5.1 kids' client (FR-7, FR-14, FR-18).

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

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

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-13 20:25:19 +11:00
parent e018c3bfa8
commit b534afb96d
19 changed files with 1119 additions and 26 deletions

View File

@@ -1,8 +1,8 @@
# Kids' Android client — Phase 5.1
# Kids' Android client — Phase 5 (5.1 + 5.2)
The core no-wipe block path of the kids' phone app (AGENTS.md §11 Phase 5,
FR-16 + FR-17). A normal Gradle project with its own wrapper. The toolchain is
**not** on `.13`; it builds on `.27`:
The no-wipe block path of the kids' phone app (AGENTS.md §11 Phase 5,
FR-4, FR-7, FR-14, FR-16, FR-17, FR-18). A normal Gradle project with its own
wrapper. The toolchain is **not** on `.13`; it builds on `.27`:
```bash
ssh 192.168.20.27 '~/android/build.sh internet_use_control'
@@ -16,27 +16,68 @@ That pulls this repo from Gitea and runs `./gradlew assembleDebug`. It prints
| Piece | File | Job |
|---|---|---|
| Accessibility Service | `BlockAccessibilityService.kt` | Watches the foreground package (FR-16). |
| Block overlay | `OverlayController.kt` | A full-screen `TYPE_APPLICATION_OVERLAY` window over a blocked app. |
| Block overlay | `OverlayController.kt` | A full-screen `TYPE_ACCESSIBILITY_OVERLAY` window over a blocked app. |
| Block rule | `BlockEngine.kt` | Blocked = locked **and** on the blocklist **and** not allowlisted (FR-4). |
| Config transport | `MqttController.kt` | Subscribes, retained, to `restrict/<device>/config` on the 8883 TLS plane with the device's own credentials; publishes presence and tamper on `restrict/<device>/status`. |
| Config transport | `MqttController.kt` | Subscribes, retained, to `restrict/<device>/config` on the 8883 TLS plane with the device's own credentials; publishes presence and tamper on `restrict/<device>/status`, and usage on `restrict/<device>/telemetry`. |
| Last state | `AppPrefs.kt` | The last config is kept on disk, so a broker-less device keeps its rules (FR-14). |
| Tamper alert | `TamperReporter.kt` | Reports a disabled service, a revoked overlay permission, or a force-stop (FR-17). |
| Tamper alert | `TamperReporter.kt` | Reports a disabled service, a revoked overlay permission, a force-stop or a revoked VPN (FR-17). |
| Status screen | `MainActivity.kt` | What is blocked, until when, and the health of the enforcement. |
| Usage access | `UsageCollector.kt` | Reads the OS per-app foreground total for the day (FR-7). |
| Local roll-up | `TelemetryStore.kt` | Keeps the highest daily total per app across restarts (FR-7, FR-14). |
| Telemetry timer | `TelemetryReporter.kt` | Samples every 60 s and publishes the day's totals. |
| Site filter | `SiteFilterVpnService.kt` | A device-local `VpnService` that answers NXDOMAIN for a blocked host (FR-18). |
| Host matching | `SiteFilter.kt` | Exact/subdomain matching; the allowlist always wins (FR-4). |
| DNS/IP wire format | `DnsMessage.kt`, `IpPacket.kt` | Minimal parse/build for the DNS forwarder. |
**No Device Owner. No factory reset. No managed profile.** The child can disable
the client; the enforcement is the tamper alert, not the overlay.
## How the tamper alert reaches Sam
## How the telemetry path works (FR-7)
The device's broker ACL only lets it write its own `status` topic, so the tamper
report rides there:
`PACKAGE_USAGE_STATS` is a privileged app-op, granted in **Settings → Special app
access → Usage access**, not at install. Every 60 s the reporter reads
`UsageStatsManager.queryUsageStats(INTERVAL_DAILY, startOfDay, now)`, which is a
cumulative total for the day, merges it into the local roll-up (keeping the
higher value), and publishes the list form the Control Service accepts:
```json
{"online":true,"tamper":{"kind":"accessibility_disabled","detail":"the service was destroyed","at":"2026-10-13T08:00:00Z"}}
{"day":"2026-10-13","apps":[{"name":"com.android.chrome","seconds":120}]}
```
`internal/mqtt` parses it, `internal/alert` records a `tamper` alert and delivers
it through Apprise, and `cmd/control` wires the two together.
The message goes to `restrict/<device>/telemetry` over the 8883 TLS plane. It is
not retained. If the broker is down the roll-up stays on disk and is republished
as soon as the link returns (`MqttController.setOnConnected`).
## How the site filter works (FR-18)
`SiteFilterVpnService` is an ordinary `VpnService`. It adds a fake resolver
address (`10.111.222.2`) and routes **only** that address into its own tunnel, so
normal traffic never enters it and no router, Pi-hole or DNS server is touched.
It reads UDP DNS on port 53, and:
- while the device is locked, a query whose name matches a blocklist entry (and
not an allowlist entry) gets **NXDOMAIN**;
- every other query is forwarded to `1.1.1.1` through a `protect()`ed socket.
The allowlist always wins (FR-4) and nothing is blocked while unlocked. The VPN
is **not** the enforcement — the child can stop it — so a system revocation
reports a `vpn_revoked` tamper (FR-17).
### Known limitation: VPN consent is manual
Android shows the VPN consent dialog the first time `VpnService.prepare()` is
called. **`adb` cannot grant it**, so the emulator proof below cannot turn the
site filter on. The filter is therefore **implemented and unit-tested, not
runtime-verified**; it must be consented and checked on a real phone. Do not
claim the VPN path is verified until that is done.
## Tests
The wire-format and matching logic is pure JVM code and runs on `.27`:
```bash
ssh 192.168.20.27 'cd ~/android/internet_use_control/android && ./gradlew testDebugUnitTest'
```
## Test on the headless emulator
@@ -47,6 +88,7 @@ ssh 192.168.20.27 '~/android/emu.sh install /tmp/webrestrict-pre/android/app/bui
PKG=au.audasmedia.webrestrict.kids
adb shell appops set $PKG SYSTEM_ALERT_WINDOW allow
adb shell appops set $PKG GET_USAGE_STATS allow
adb shell settings put secure enabled_accessibility_services $PKG/$PKG.BlockAccessibilityService
adb shell settings put secure accessibility_enabled 1
@@ -58,6 +100,19 @@ ssh 192.168.20.27 '~/android/emu.sh screenshot /tmp/shot.png'
# Show the tamper alert: disable the service.
adb shell settings put secure accessibility_enabled 0
adb logcat -d -s WebRestrictTamper
ssh 192.168.20.27 '~/android/emu.sh stop'
```
## Provisioning the broker on a real device
The lab CA and the per-device MQTT account are not in the repository. Push them
instead of rebuilding the app:
```bash
PKG=au.audasmedia.webrestrict.kids
adb shell "run-as $PKG sh -c 'cat > files/lab-ca.pem'" < deploy/mosquitto/tls/ca.crt
# then set the broker fields on the status screen, including:
# CA file (lab) = /data/data/$PKG/files/lab-ca.pem
```
The device topic is `restrict/<token>/config`, where `<token>` is the middle
level and also the MQTT account name. `AppPrefs.caFile` wins over `caAsset`.

View File

@@ -12,7 +12,7 @@ android {
minSdk = 26
targetSdk = 35
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.
// A real device receives the retained config over MQTT and overwrites

View File

@@ -1,17 +1,24 @@
<?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
app that asks for the Accessibility Service and the overlay permission. The
child can disable it; the enforcement is the tamper alert, not the overlay.
app that asks for the Accessibility Service, the overlay permission, Usage
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.ACCESS_NETWORK_STATE" />
<!-- The full-screen block overlay is a TYPE_APPLICATION_OVERLAY 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. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
@@ -53,6 +60,19 @@
android:resource="@xml/accessibility_service_config" />
</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
to the broker so the retained config is fetched at once. -->
<receiver

View File

@@ -43,6 +43,16 @@ class AppPrefs(context: Context) {
get() = prefs.getString(KEY_CA_ASSET, "")!!
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). */
var configJson: String?
get() = prefs.getString(KEY_CONFIG, null)
@@ -75,6 +85,9 @@ class AppPrefs(context: Context) {
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. */
fun topicToken(): String =
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_PASSWORD = "mqtt_password"
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_HEARTBEAT = "heartbeat_at"
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()
TamperReporter.detectForceStop(this)
MqttController.start(this)
// FR-7: sample Usage Access and publish the day's per-app totals.
TelemetryReporter.start(this)
} catch (e: Exception) {
// A failure here must never turn on the crash dialog for a kid.
Log.e(TAG, "startup failed: ${e.message}")

View File

@@ -4,6 +4,7 @@ import android.content.ComponentName
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.net.VpnService
import android.os.Bundle
import android.provider.Settings
import android.text.InputType
@@ -45,6 +46,14 @@ class MainActivity : AppCompatActivity() {
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() {
container.removeAllViews()
val prefs = AppPrefs(this)
@@ -77,6 +86,25 @@ class MainActivity : AppCompatActivity() {
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")
button("Open 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") {
ConfigStore(this).save(DeviceConfig.default())
refresh()
@@ -104,10 +146,25 @@ class MainActivity : AppCompatActivity() {
it.toIntOrNull()?.let { port -> prefs.brokerPort = port }
}
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 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 {
val expected = ComponentName(this, BlockAccessibilityService::class.java)
val enabled = Settings.Secure.getString(
@@ -186,4 +243,8 @@ class MainActivity : AppCompatActivity() {
private fun dp(value: Int): Int =
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.MqttMessage
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence
import java.io.File
import java.io.InputStream
import java.security.KeyStore
import java.security.cert.CertificateFactory
@@ -60,6 +61,9 @@ object MqttController {
@Volatile
private var configListener: ((DeviceConfig) -> Unit)? = null
@Volatile
private var connectedListener: (() -> Unit)? = null
fun isConnected(): Boolean = connected
fun lastError(): String? = lastError
@@ -69,6 +73,15 @@ object MqttController {
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. */
fun start(context: Context) {
if (client != null) return
@@ -127,6 +140,7 @@ object MqttController {
Log.i(TAG, "mqtt connected to ${prefs.brokerHost}:${prefs.brokerPort}")
subscribe(c, prefs)
publishStatus(app, null)
connectedListener?.invoke()
}
override fun onFailure(token: IMqttToken?, exception: Throwable?) {
@@ -160,6 +174,7 @@ object MqttController {
Log.i(TAG, "mqtt connectComplete reconnect=$reconnect")
subscribe(c, prefs)
publishStatus(app, null)
connectedListener?.invoke()
}
override fun connectionLost(cause: Throwable?) {
@@ -197,6 +212,24 @@ object MqttController {
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). */
fun publishTamper(context: Context, kind: String, detail: String) {
val prefs = AppPrefs(context)
@@ -235,14 +268,25 @@ object MqttController {
}
/**
* Builds the TLS socket factory. With no CA asset configured the platform
* trust 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.
* Builds the TLS socket factory. With no CA configured the platform trust
* store is used, which is what a public broker would need. A lab CA is
* 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? {
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 {
val certs = context.assets.open(prefs.caAsset).use(::readCertificates)
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply {
load(null, null)
certs.forEachIndexed { i, cert -> setCertificateEntry("ca$i", cert) }
@@ -251,7 +295,7 @@ object MqttController {
tmf.init(keyStore)
SSLContext.getInstance("TLS").apply { init(null, tmf.trustManagers, null) }.socketFactory
} catch (e: Exception) {
Log.w(TAG, "cannot load CA asset ${prefs.caAsset}: ${e.message}")
Log.w(TAG, "cannot build TLS context: ${e.message}")
null
}
}
@@ -266,6 +310,10 @@ object MqttController {
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(
val online: Boolean,
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
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>
<string name="vpn_label">Screen Limits site filter</string>
</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)
}
}