diff --git a/android/README.md b/android/README.md index 3d8556b..f55e647 100644 --- a/android/README.md +++ b/android/README.md @@ -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//config` on the 8883 TLS plane with the device's own credentials; publishes presence and tamper on `restrict//status`. | +| Config transport | `MqttController.kt` | Subscribes, retained, to `restrict//config` on the 8883 TLS plane with the device's own credentials; publishes presence and tamper on `restrict//status`, and usage on `restrict//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//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//config`, where `` is the middle +level and also the MQTT account name. `AppPrefs.caFile` wins over `caAsset`. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4daa71a..657e2eb 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1f095d5..355c633 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,17 +1,24 @@ - + + + @@ -53,6 +60,19 @@ android:resource="@xml/accessibility_service_config" /> + + + + + + + /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" diff --git a/android/app/src/main/java/au/audasmedia/webrestrict/kids/DnsMessage.kt b/android/app/src/main/java/au/audasmedia/webrestrict/kids/DnsMessage.kt new file mode 100644 index 0000000..34bf970 --- /dev/null +++ b/android/app/src/main/java/au/audasmedia/webrestrict/kids/DnsMessage.kt @@ -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() + } +} diff --git a/android/app/src/main/java/au/audasmedia/webrestrict/kids/IpPacket.kt b/android/app/src/main/java/au/audasmedia/webrestrict/kids/IpPacket.kt new file mode 100644 index 0000000..9273d75 --- /dev/null +++ b/android/app/src/main/java/au/audasmedia/webrestrict/kids/IpPacket.kt @@ -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 + } +} diff --git a/android/app/src/main/java/au/audasmedia/webrestrict/kids/KidsApp.kt b/android/app/src/main/java/au/audasmedia/webrestrict/kids/KidsApp.kt index 7687b6f..6a7fe05 100644 --- a/android/app/src/main/java/au/audasmedia/webrestrict/kids/KidsApp.kt +++ b/android/app/src/main/java/au/audasmedia/webrestrict/kids/KidsApp.kt @@ -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}") diff --git a/android/app/src/main/java/au/audasmedia/webrestrict/kids/MainActivity.kt b/android/app/src/main/java/au/audasmedia/webrestrict/kids/MainActivity.kt index 4939f2b..7a72379 100644 --- a/android/app/src/main/java/au/audasmedia/webrestrict/kids/MainActivity.kt +++ b/android/app/src/main/java/au/audasmedia/webrestrict/kids/MainActivity.kt @@ -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 + } } diff --git a/android/app/src/main/java/au/audasmedia/webrestrict/kids/MqttController.kt b/android/app/src/main/java/au/audasmedia/webrestrict/kids/MqttController.kt index 899692e..ae628e3 100644 --- a/android/app/src/main/java/au/audasmedia/webrestrict/kids/MqttController.kt +++ b/android/app/src/main/java/au/audasmedia/webrestrict/kids/MqttController.kt @@ -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) { + 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) + private data class StatusPayload( val online: Boolean, val app: String? = null, diff --git a/android/app/src/main/java/au/audasmedia/webrestrict/kids/SiteFilter.kt b/android/app/src/main/java/au/audasmedia/webrestrict/kids/SiteFilter.kt new file mode 100644 index 0000000..1b81dc1 --- /dev/null +++ b/android/app/src/main/java/au/audasmedia/webrestrict/kids/SiteFilter.kt @@ -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? { + 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 + } +} diff --git a/android/app/src/main/java/au/audasmedia/webrestrict/kids/SiteFilterVpnService.kt b/android/app/src/main/java/au/audasmedia/webrestrict/kids/SiteFilterVpnService.kt new file mode 100644 index 0000000..ea01bcd --- /dev/null +++ b/android/app/src/main/java/au/audasmedia/webrestrict/kids/SiteFilterVpnService.kt @@ -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) + } + } +} diff --git a/android/app/src/main/java/au/audasmedia/webrestrict/kids/TelemetryReporter.kt b/android/app/src/main/java/au/audasmedia/webrestrict/kids/TelemetryReporter.kt new file mode 100644 index 0000000..f93435a --- /dev/null +++ b/android/app/src/main/java/au/audasmedia/webrestrict/kids/TelemetryReporter.kt @@ -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) { + 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" }}\"") + } +} diff --git a/android/app/src/main/java/au/audasmedia/webrestrict/kids/TelemetryStore.kt b/android/app/src/main/java/au/audasmedia/webrestrict/kids/TelemetryStore.kt new file mode 100644 index 0000000..5759477 --- /dev/null +++ b/android/app/src/main/java/au/audasmedia/webrestrict/kids/TelemetryStore.kt @@ -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> = load() + + /** Merges one sample into the day and returns the day's full roll-up. */ + fun record(day: String, samples: Map): Map { + val merged = merge(days[day] ?: emptyMap(), samples) + val row = merged.toMutableMap() + days[day] = row + val kept = prune(days, MAX_DAYS) + days = HashMap>().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 = 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> { + val json = prefs.getString(KEY_DAYS, null) ?: return mutableMapOf() + return try { + val type = object : TypeToken>>() {}.type + val parsed: MutableMap>? = 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, samples: Map): Map { + val out = HashMap(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>, + max: Int, + ): Map> = + days.entries + .sortedBy { it.key } + .takeLast(max) + .associate { it.key to it.value } + } +} diff --git a/android/app/src/main/java/au/audasmedia/webrestrict/kids/UsageCollector.kt b/android/app/src/main/java/au/audasmedia/webrestrict/kids/UsageCollector.kt new file mode 100644 index 0000000..f09b38b --- /dev/null +++ b/android/app/src/main/java/au/audasmedia/webrestrict/kids/UsageCollector.kt @@ -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 { + 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() + 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 +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index a545cf9..0cae572 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -5,4 +5,5 @@ 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. + Screen Limits site filter diff --git a/android/app/src/test/java/au/audasmedia/webrestrict/kids/DnsMessageTest.kt b/android/app/src/test/java/au/audasmedia/webrestrict/kids/DnsMessageTest.kt new file mode 100644 index 0000000..b9dfa5d --- /dev/null +++ b/android/app/src/test/java/au/audasmedia/webrestrict/kids/DnsMessageTest.kt @@ -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() + } +} diff --git a/android/app/src/test/java/au/audasmedia/webrestrict/kids/IpPacketTest.kt b/android/app/src/test/java/au/audasmedia/webrestrict/kids/IpPacketTest.kt new file mode 100644 index 0000000..83bb097 --- /dev/null +++ b/android/app/src/test/java/au/audasmedia/webrestrict/kids/IpPacketTest.kt @@ -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)) + } +} diff --git a/android/app/src/test/java/au/audasmedia/webrestrict/kids/SiteFilterTest.kt b/android/app/src/test/java/au/audasmedia/webrestrict/kids/SiteFilterTest.kt new file mode 100644 index 0000000..5ae1904 --- /dev/null +++ b/android/app/src/test/java/au/audasmedia/webrestrict/kids/SiteFilterTest.kt @@ -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")) + } +} diff --git a/android/app/src/test/java/au/audasmedia/webrestrict/kids/TelemetryStoreTest.kt b/android/app/src/test/java/au/audasmedia/webrestrict/kids/TelemetryStoreTest.kt new file mode 100644 index 0000000..4b362ef --- /dev/null +++ b/android/app/src/test/java/au/audasmedia/webrestrict/kids/TelemetryStoreTest.kt @@ -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(), 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) + } +}