Phase 5.2: Android Usage Access telemetry + VpnService site filtering
Continue the Phase 5.1 kids' client (FR-7, FR-14, FR-18). - Usage Access telemetry: UsageCollector reads the OS per-app foreground total for the day, TelemetryStore keeps the highest value per (day, app) across restarts, and TelemetryReporter samples every 60 s and publishes the list form on restrict/<device>/telemetry. Queued rows republish when the broker link returns. - Device-local VpnService: SiteFilterVpnService routes only a fake resolver into its own tunnel, answers NXDOMAIN for a blocked host while locked, and forwards everything else upstream through a protected socket. The allowlist always wins (FR-4). No router, Pi-hole or DNS change. - MQTT link: caFile support plus the telemetry topic and payload. - Status screen: Usage Access, telemetry totals and site-filter state. - Unit tests for host matching, DNS parse/NXDOMAIN and IPv4/UDP round trip. No Device Owner, no factory reset, no managed profile. The VPN consent dialog is manual; adb cannot grant it (recorded in android/README.md). Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
# Kids' Android client — Phase 5.1
|
||||
# Kids' Android client — Phase 5 (5.1 + 5.2)
|
||||
|
||||
The core no-wipe block path of the kids' phone app (AGENTS.md §11 Phase 5,
|
||||
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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package au.audasmedia.webrestrict.kids
|
||||
|
||||
/**
|
||||
* The DNS wire-format pieces the site filter needs (FR-18). It is deliberately
|
||||
* minimal: the tunnel only carries UDP DNS to the fake resolver, so this parses
|
||||
* a single question and can build an NXDOMAIN answer. Pure, unit-tested.
|
||||
*/
|
||||
object DnsMessage {
|
||||
|
||||
/** A parsed question: the lower-case name and the offset just past the question. */
|
||||
data class Question(val name: String, val end: Int)
|
||||
|
||||
/**
|
||||
* Parses the question section of a DNS query. Returns null for anything
|
||||
* that is not a single, uncompressed question: a response, a multi-question
|
||||
* packet, a compression pointer or a truncated/unknown record.
|
||||
*/
|
||||
fun parseQuestion(packet: ByteArray, length: Int): Question? {
|
||||
if (length < HEADER_SIZE) return null
|
||||
if (u16(packet, 4) != 1) return null // QDCOUNT
|
||||
if (u16(packet, 2) and 0x8000 != 0) return null // QR: it is a response
|
||||
var pos = HEADER_SIZE
|
||||
val name = StringBuilder()
|
||||
while (pos < length) {
|
||||
val len = packet[pos].toInt() and 0xFF
|
||||
if (len == 0) {
|
||||
pos++
|
||||
break
|
||||
}
|
||||
// A question name is not compressed. A pointer here means the packet
|
||||
// is not what we expect, so refuse to parse rather than guess.
|
||||
if (len and 0xC0 == 0xC0) return null
|
||||
pos++
|
||||
if (len > 63 || pos + len > length) return null
|
||||
if (name.isNotEmpty()) name.append('.')
|
||||
name.append(String(packet, pos, len, Charsets.US_ASCII))
|
||||
pos += len
|
||||
}
|
||||
if (pos + 4 > length) return null // QTYPE + QCLASS
|
||||
val qname = name.toString().lowercase()
|
||||
if (qname.isEmpty()) return null
|
||||
return Question(qname, pos + 4)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an NXDOMAIN answer for [query]. The question is echoed and every
|
||||
* answer/authority/additional count is zeroed. NXDOMAIN is used instead of
|
||||
* `0.0.0.0` so a blocked name is a clean "does not exist", not a connection
|
||||
* to a bogus host.
|
||||
*/
|
||||
fun nxdomain(query: ByteArray, questionEnd: Int): ByteArray {
|
||||
val end = questionEnd.coerceIn(HEADER_SIZE, query.size)
|
||||
val out = query.copyOf(end)
|
||||
out[2] = (out[2].toInt() or 0x80).toByte() // QR = 1
|
||||
out[3] = ((out[3].toInt() and 0xF0) or 0x03).toByte() // RCODE = NXDOMAIN
|
||||
putU16(out, 6, 0) // ANCOUNT
|
||||
putU16(out, 8, 0) // NSCOUNT
|
||||
putU16(out, 10, 0) // ARCOUNT
|
||||
return out
|
||||
}
|
||||
|
||||
private const val HEADER_SIZE = 12
|
||||
|
||||
private fun u16(b: ByteArray, i: Int): Int =
|
||||
((b[i].toInt() and 0xFF) shl 8) or (b[i + 1].toInt() and 0xFF)
|
||||
|
||||
private fun putU16(b: ByteArray, i: Int, v: Int) {
|
||||
b[i] = ((v ushr 8) and 0xFF).toByte()
|
||||
b[i + 1] = (v and 0xFF).toByte()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package au.audasmedia.webrestrict.kids
|
||||
|
||||
/**
|
||||
* The small slice of the IPv4/UDP wire format the DNS forwarder needs (FR-18).
|
||||
* The VPN route carries only UDP to the fake resolver, so nothing else arrives
|
||||
* on the tunnel. Pure, unit-tested.
|
||||
*/
|
||||
data class UdpDatagram(
|
||||
val srcIp: ByteArray,
|
||||
val dstIp: ByteArray,
|
||||
val srcPort: Int,
|
||||
val dstPort: Int,
|
||||
val payload: ByteArray,
|
||||
)
|
||||
|
||||
object IpPacket {
|
||||
|
||||
/** Parses one IPv4/UDP packet, or null for anything else. */
|
||||
fun parseUdp(packet: ByteArray, length: Int): UdpDatagram? {
|
||||
if (length < 28) return null
|
||||
val version = (packet[0].toInt() and 0xF0) ushr 4
|
||||
if (version != 4) return null
|
||||
val ihl = (packet[0].toInt() and 0x0F) * 4
|
||||
if (ihl < 20 || ihl + 8 > length) return null
|
||||
if ((packet[9].toInt() and 0xFF) != 17) return null // protocol: UDP
|
||||
|
||||
val udpLen = u16(packet, ihl + 4)
|
||||
val payloadStart = ihl + 8
|
||||
val payloadEnd = minOf(length, ihl + udpLen)
|
||||
if (payloadEnd <= payloadStart) return null
|
||||
|
||||
return UdpDatagram(
|
||||
srcIp = packet.copyOfRange(12, 16),
|
||||
dstIp = packet.copyOfRange(16, 20),
|
||||
srcPort = u16(packet, ihl),
|
||||
dstPort = u16(packet, ihl + 2),
|
||||
payload = packet.copyOfRange(payloadStart, payloadEnd),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds one IPv4/UDP packet. The UDP checksum is left as 0, which IPv4
|
||||
* permits ("no checksum"); the IP header checksum is computed.
|
||||
*/
|
||||
fun buildUdp(
|
||||
srcIp: ByteArray,
|
||||
dstIp: ByteArray,
|
||||
srcPort: Int,
|
||||
dstPort: Int,
|
||||
payload: ByteArray,
|
||||
): ByteArray {
|
||||
val total = 20 + 8 + payload.size
|
||||
val out = ByteArray(total)
|
||||
out[0] = 0x45
|
||||
putU16(out, 2, total)
|
||||
putU16(out, 4, 0)
|
||||
putU16(out, 6, 0)
|
||||
out[8] = 64 // TTL
|
||||
out[9] = 17 // UDP
|
||||
System.arraycopy(srcIp, 0, out, 12, 4)
|
||||
System.arraycopy(dstIp, 0, out, 16, 4)
|
||||
putU16(out, 10, checksum(out, 0, 20))
|
||||
putU16(out, 20, srcPort)
|
||||
putU16(out, 22, dstPort)
|
||||
putU16(out, 24, 8 + payload.size)
|
||||
putU16(out, 26, 0)
|
||||
System.arraycopy(payload, 0, out, 28, payload.size)
|
||||
return out
|
||||
}
|
||||
|
||||
fun u16(b: ByteArray, i: Int): Int =
|
||||
((b[i].toInt() and 0xFF) shl 8) or (b[i + 1].toInt() and 0xFF)
|
||||
|
||||
fun putU16(b: ByteArray, i: Int, v: Int) {
|
||||
b[i] = ((v ushr 8) and 0xFF).toByte()
|
||||
b[i + 1] = (v and 0xFF).toByte()
|
||||
}
|
||||
|
||||
/** The standard internet checksum over [len] bytes starting at [offset]. */
|
||||
fun checksum(b: ByteArray, offset: Int, len: Int): Int {
|
||||
var sum = 0
|
||||
var i = offset
|
||||
val end = offset + len
|
||||
while (i + 1 < end) {
|
||||
sum += u16(b, i)
|
||||
i += 2
|
||||
}
|
||||
if (i < end) sum += (b[i].toInt() and 0xFF) shl 8
|
||||
while (sum ushr 16 != 0) sum = (sum and 0xFFFF) + (sum ushr 16)
|
||||
return sum.inv() and 0xFFFF
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ class KidsApp : Application() {
|
||||
ConfigStore(this).seedIfEmpty()
|
||||
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}")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package au.audasmedia.webrestrict.kids
|
||||
|
||||
/**
|
||||
* Hostname filtering for the device-local VpnService (FR-18).
|
||||
*
|
||||
* The Phase 3 config payload carries the block and allow values as one flat
|
||||
* list ([DeviceConfig.block] / [DeviceConfig.allow]); the list kind ("app" or
|
||||
* "site") is not on the wire. A value is matched here as a hostname, by exact
|
||||
* name or by subdomain, so a site entry such as `youtube.com` covers
|
||||
* `www.youtube.com` and an app identifier such as `com.android.chrome` never
|
||||
* matches a real hostname in practice.
|
||||
*
|
||||
* A host is blocked only while the device is locked, and an allowlist entry
|
||||
* always wins, which is FR-4 ("Spotify, school sites"). The client never
|
||||
* widens or narrows the rules itself (AGENTS.md §10 rule 7).
|
||||
*
|
||||
* Pure logic: unit-tested without an emulator.
|
||||
*/
|
||||
object SiteFilter {
|
||||
|
||||
/** The first block/allow value that matches [host], or null. */
|
||||
fun matchHost(host: String, entries: List<String>): String? {
|
||||
val h = normalizeHost(host) ?: return null
|
||||
for (entry in entries) {
|
||||
val v = normalizeHost(entry) ?: continue
|
||||
if (h == v || h.endsWith("." + v)) return entry
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* A usable hostname: non-blank, no whitespace or path separator, lower case
|
||||
* and without a trailing root dot. Returns null when the value cannot be a
|
||||
* hostname, so a stray credential or comment can never match.
|
||||
*/
|
||||
fun normalizeHost(value: String?): String? {
|
||||
if (value.isNullOrBlank()) return null
|
||||
val v = value.trim().trim('.').lowercase()
|
||||
if (v.isEmpty()) return null
|
||||
if (v.any { it.isWhitespace() || it == '/' || it == '\\' }) return null
|
||||
return v
|
||||
}
|
||||
|
||||
/** Whether [host] must be refused while the current config is applied. */
|
||||
fun isBlocked(host: String, config: DeviceConfig): Boolean {
|
||||
if (!config.locked) return false
|
||||
if (matchHost(host, config.block) == null) return false
|
||||
return matchHost(host, config.allow) == null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package au.audasmedia.webrestrict.kids
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.net.DatagramPacket
|
||||
import java.net.DatagramSocket
|
||||
import java.net.InetAddress
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Device-local hostname filtering (FR-18). No router, no Pi-hole, no DNS-server
|
||||
* change: the app starts an ordinary Android `VpnService`, routes only a fake
|
||||
* resolver address into its own tunnel, and answers DNS itself.
|
||||
*
|
||||
* A query for a blocked host gets NXDOMAIN. Every other query is forwarded to
|
||||
* an upstream resolver through a protected socket, so only DNS is touched and
|
||||
* normal traffic never enters the tunnel. The allowlist always wins (FR-4), and
|
||||
* nothing is blocked while the device is unlocked.
|
||||
*
|
||||
* Consent cannot be granted by adb: [VpnService.prepare] shows a system dialog.
|
||||
* That part is manual on a real device (see README).
|
||||
*/
|
||||
class SiteFilterVpnService : VpnService() {
|
||||
|
||||
private val running = AtomicBoolean(false)
|
||||
private var tun: ParcelFileDescriptor? = null
|
||||
private var loop: Thread? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
stopFilter()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
startFilter()
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun startFilter() {
|
||||
if (running.get()) return
|
||||
val builder = Builder()
|
||||
.setSession("WebRestrict site filter")
|
||||
.addAddress(IFACE_ADDRESS, 32)
|
||||
.addDnsServer(FAKE_DNS)
|
||||
.addRoute(FAKE_DNS, 32)
|
||||
.setMtu(1500)
|
||||
val pfd = try {
|
||||
builder.establish()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "cannot establish the tunnel: ${e.message}")
|
||||
null
|
||||
}
|
||||
if (pfd == null) {
|
||||
Log.w(TAG, "VPN consent missing or refused; not filtering")
|
||||
return
|
||||
}
|
||||
tun = pfd
|
||||
running.set(true)
|
||||
isRunning = true
|
||||
MqttController.publishStatus(this, null)
|
||||
loop = Thread({ readLoop(pfd) }, "webrestrict-vpn").apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
}
|
||||
Log.i(TAG, "site filter started iface=$IFACE_ADDRESS dns=$FAKE_DNS upstream=$UPSTREAM_DNS")
|
||||
}
|
||||
|
||||
private fun readLoop(pfd: ParcelFileDescriptor) {
|
||||
val input = FileInputStream(pfd.fileDescriptor)
|
||||
val output = FileOutputStream(pfd.fileDescriptor)
|
||||
val buffer = ByteArray(32767)
|
||||
val upstream = DatagramSocket()
|
||||
try {
|
||||
protect(upstream)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "cannot protect the upstream socket: ${e.message}")
|
||||
}
|
||||
upstream.soTimeout = UPSTREAM_TIMEOUT_MS
|
||||
|
||||
try {
|
||||
while (running.get()) {
|
||||
val n = try {
|
||||
input.read(buffer)
|
||||
} catch (e: Exception) {
|
||||
if (running.get()) Log.w(TAG, "tunnel read: ${e.message}")
|
||||
break
|
||||
}
|
||||
if (n <= 0) continue
|
||||
handle(buffer, n, output, upstream)
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
upstream.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
try {
|
||||
input.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
try {
|
||||
output.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handle(
|
||||
packet: ByteArray,
|
||||
length: Int,
|
||||
output: FileOutputStream,
|
||||
upstream: DatagramSocket,
|
||||
) {
|
||||
val udp = IpPacket.parseUdp(packet, length) ?: return
|
||||
if (udp.dstPort != DNS_PORT) return
|
||||
val question = DnsMessage.parseQuestion(udp.payload, udp.payload.size) ?: return
|
||||
|
||||
val config = ConfigStore(this).load()
|
||||
val blocked = SiteFilter.isBlocked(question.name, config)
|
||||
Log.d(TAG, "dns ${question.name} blocked=$blocked locked=${config.locked}")
|
||||
|
||||
val response = if (blocked) {
|
||||
DnsMessage.nxdomain(udp.payload, question.end)
|
||||
} else {
|
||||
forward(udp.payload, upstream) ?: return
|
||||
}
|
||||
|
||||
// Swap source and destination: reply to the client that asked.
|
||||
val reply = IpPacket.buildUdp(udp.dstIp, udp.srcIp, udp.dstPort, udp.srcPort, response)
|
||||
try {
|
||||
output.write(reply)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "tunnel write: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends the raw query upstream and returns the raw response, or null. */
|
||||
private fun forward(query: ByteArray, socket: DatagramSocket): ByteArray? {
|
||||
return try {
|
||||
val upstream = InetAddress.getByName(UPSTREAM_DNS)
|
||||
socket.send(DatagramPacket(query, query.size, upstream, DNS_PORT))
|
||||
val buf = ByteArray(4096)
|
||||
val response = DatagramPacket(buf, buf.size)
|
||||
socket.receive(response)
|
||||
buf.copyOf(response.length)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "upstream DNS failed: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopFilter() {
|
||||
if (!running.getAndSet(false)) return
|
||||
isRunning = false
|
||||
try {
|
||||
tun?.close()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "cannot close the tunnel: ${e.message}")
|
||||
}
|
||||
tun = null
|
||||
loop = null
|
||||
Log.i(TAG, "site filter stopped")
|
||||
}
|
||||
|
||||
override fun onRevoke() {
|
||||
TamperReporter.report(this, "vpn_revoked", "the system revoked the VPN or another VPN took over")
|
||||
stopFilter()
|
||||
super.onRevoke()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopFilter()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_START = "au.audasmedia.webrestrict.kids.action.START_VPN"
|
||||
const val ACTION_STOP = "au.audasmedia.webrestrict.kids.action.STOP_VPN"
|
||||
|
||||
private const val TAG = "WebRestrictVpn"
|
||||
private const val IFACE_ADDRESS = "10.111.222.1"
|
||||
private const val FAKE_DNS = "10.111.222.2"
|
||||
private const val UPSTREAM_DNS = "1.1.1.1"
|
||||
private const val DNS_PORT = 53
|
||||
private const val UPSTREAM_TIMEOUT_MS = 4000
|
||||
|
||||
/** Whether the tunnel is established, for the status screen. */
|
||||
@Volatile
|
||||
var isRunning: Boolean = false
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, SiteFilterVpnService::class.java).setAction(ACTION_START)
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
val intent = Intent(context, SiteFilterVpnService::class.java).setAction(ACTION_STOP)
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package au.audasmedia.webrestrict.kids
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Samples Usage Access on a timer and publishes the day's per-app totals to the
|
||||
* Phase 3 telemetry topic (FR-7).
|
||||
*
|
||||
* The sample runs off the main thread. The local roll-up in [TelemetryStore] is
|
||||
* written first, so an offline device keeps its usage and republishes it the
|
||||
* moment the broker link returns (FR-14). A device without Usage Access is
|
||||
* simply silent: it does not crash and does not publish empty rows.
|
||||
*/
|
||||
object TelemetryReporter {
|
||||
private const val TAG = "WebRestrictTelemetry"
|
||||
private const val SAMPLE_MS = 60_000L
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val executor = Executors.newSingleThreadExecutor { r ->
|
||||
Thread(r, "webrestrict-telemetry").apply { isDaemon = true }
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var app: Context? = null
|
||||
|
||||
@Volatile
|
||||
private var started = false
|
||||
|
||||
private val tick = object : Runnable {
|
||||
override fun run() {
|
||||
sampleAsync()
|
||||
main.postDelayed(this, SAMPLE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts the sampler once. Safe to call from Application and Activity. */
|
||||
fun start(context: Context) {
|
||||
app = context.applicationContext
|
||||
if (started) return
|
||||
started = true
|
||||
MqttController.setOnConnected { publishLatest() }
|
||||
main.post(tick)
|
||||
}
|
||||
|
||||
/** Reads and publishes immediately, for the status screen. */
|
||||
fun sampleNow() {
|
||||
sampleAsync()
|
||||
}
|
||||
|
||||
private fun sampleAsync() {
|
||||
val ctx = app ?: return
|
||||
executor.execute {
|
||||
try {
|
||||
if (!UsageCollector.hasUsageAccess(ctx)) return@execute
|
||||
val now = System.currentTimeMillis()
|
||||
val day = UsageCollector.dayKey(now)
|
||||
val sample = UsageCollector.sample(ctx, now)
|
||||
if (sample.isEmpty()) return@execute
|
||||
val merged = TelemetryStore(ctx).record(day, sample)
|
||||
publish(ctx, day, merged)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "telemetry sample failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Republishes the last roll-up, used when the broker link comes back. */
|
||||
private fun publishLatest() {
|
||||
val ctx = app ?: return
|
||||
executor.execute {
|
||||
val now = System.currentTimeMillis()
|
||||
val day = UsageCollector.dayKey(now)
|
||||
val rows = TelemetryStore(ctx).day(day)
|
||||
if (rows.isNotEmpty()) publish(ctx, day, rows)
|
||||
}
|
||||
}
|
||||
|
||||
private fun publish(ctx: Context, day: String, rows: Map<String, Int>) {
|
||||
MqttController.publishTelemetry(ctx, day, rows)
|
||||
TelemetryStore(ctx).markPublished(System.currentTimeMillis())
|
||||
Log.i(TAG, "telemetry day=$day apps=${rows.size} sample=\"${rows.entries.take(3).joinToString { "${it.key}=${it.value}s" }}\"")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package au.audasmedia.webrestrict.kids
|
||||
|
||||
import android.content.Context
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Local daily roll-up of Usage Access samples. It survives a restart and a
|
||||
* broker outage (FR-14): the OS supplies a cumulative daily total, and this
|
||||
* store keeps the highest total seen per (day, package), so a stats reset or a
|
||||
* restart can never lower a total that was already published.
|
||||
*
|
||||
* The format written is the list form the Phase 3 telemetry topic accepts:
|
||||
* `{"day":"YYYY-MM-DD","apps":[{"name":"pkg","seconds":N}]}`.
|
||||
*/
|
||||
class TelemetryStore(context: Context) {
|
||||
private val prefs = context.applicationContext
|
||||
.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
private val gson = Gson()
|
||||
|
||||
/** day -> package -> seconds. */
|
||||
private var days: MutableMap<String, MutableMap<String, Int>> = load()
|
||||
|
||||
/** Merges one sample into the day and returns the day's full roll-up. */
|
||||
fun record(day: String, samples: Map<String, Int>): Map<String, Int> {
|
||||
val merged = merge(days[day] ?: emptyMap(), samples)
|
||||
val row = merged.toMutableMap()
|
||||
days[day] = row
|
||||
val kept = prune(days, MAX_DAYS)
|
||||
days = HashMap<String, MutableMap<String, Int>>().apply {
|
||||
for ((k, v) in kept) put(k, v.toMutableMap())
|
||||
}
|
||||
save()
|
||||
return row
|
||||
}
|
||||
|
||||
/** The stored roll-up for one day. */
|
||||
fun day(day: String): Map<String, Int> = days[day]?.toMap() ?: emptyMap()
|
||||
|
||||
fun lastPublishedAt(): Long = prefs.getLong(KEY_PUBLISHED, 0L)
|
||||
|
||||
fun markPublished(at: Long) {
|
||||
prefs.edit().putLong(KEY_PUBLISHED, at).apply()
|
||||
}
|
||||
|
||||
private fun load(): MutableMap<String, MutableMap<String, Int>> {
|
||||
val json = prefs.getString(KEY_DAYS, null) ?: return mutableMapOf()
|
||||
return try {
|
||||
val type = object : TypeToken<MutableMap<String, MutableMap<String, Int>>>() {}.type
|
||||
val parsed: MutableMap<String, MutableMap<String, Int>>? = gson.fromJson(json, type)
|
||||
parsed ?: mutableMapOf()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "telemetry store is unreadable, starting empty: ${e.message}")
|
||||
mutableMapOf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun save() {
|
||||
prefs.edit().putString(KEY_DAYS, gson.toJson(days)).apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WebRestrictTelemetry"
|
||||
private const val FILE = "webrestrict_telemetry"
|
||||
private const val KEY_DAYS = "days"
|
||||
private const val KEY_PUBLISHED = "last_published_at"
|
||||
private const val MAX_DAYS = 14
|
||||
|
||||
/**
|
||||
* Merges [samples] into [existing], keeping the higher total per
|
||||
* package. Pure so it is unit-tested without Android.
|
||||
*/
|
||||
fun merge(existing: Map<String, Int>, samples: Map<String, Int>): Map<String, Int> {
|
||||
val out = HashMap<String, Int>(existing)
|
||||
for ((pkg, seconds) in samples) {
|
||||
if (pkg.isBlank() || seconds <= 0) continue
|
||||
if (seconds > (out[pkg] ?: 0)) out[pkg] = seconds
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Keeps the [max] most recent days. Pure so it is unit-tested. */
|
||||
fun prune(
|
||||
days: Map<String, Map<String, Int>>,
|
||||
max: Int,
|
||||
): Map<String, Map<String, Int>> =
|
||||
days.entries
|
||||
.sortedBy { it.key }
|
||||
.takeLast(max)
|
||||
.associate { it.key to it.value }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package au.audasmedia.webrestrict.kids
|
||||
|
||||
import android.app.AppOpsManager
|
||||
import android.app.usage.UsageStatsManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Reads the OS's per-app foreground total for one day (FR-7). It needs Usage
|
||||
* Access (`PACKAGE_USAGE_STATS` / the `GET_USAGE_STATS` app-op), which the
|
||||
* system grants from Settings, not at install.
|
||||
*
|
||||
* The query is cumulative for the day, so a restart cannot lose the earlier
|
||||
* total; [TelemetryStore] keeps the highest value it has seen anyway.
|
||||
*/
|
||||
object UsageCollector {
|
||||
private const val TAG = "WebRestrictUsage"
|
||||
|
||||
/** Foreground seconds per package for the day that contains [nowMillis]. */
|
||||
fun sample(context: Context, nowMillis: Long): Map<String, Int> {
|
||||
val usm = context.getSystemService(Context.USAGE_STATS_SERVICE) as? UsageStatsManager
|
||||
?: return emptyMap()
|
||||
val start = startOfDay(nowMillis)
|
||||
val stats = try {
|
||||
usm.queryUsageStats(UsageStatsManager.INTERVAL_DAILY, start, nowMillis)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "usage query failed: ${e.message}")
|
||||
return emptyMap()
|
||||
} ?: return emptyMap()
|
||||
|
||||
val out = HashMap<String, Int>()
|
||||
for (s in stats) {
|
||||
val pkg = s.packageName ?: continue
|
||||
// The enforcer itself is not the child's screen time.
|
||||
if (pkg == context.packageName) continue
|
||||
val seconds = (s.totalTimeInForeground / 1000L).toInt()
|
||||
if (seconds <= 0) continue
|
||||
out[pkg] = (out[pkg] ?: 0) + seconds
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The YYYY-MM-DD key the Control Service expects for a report. */
|
||||
fun dayKey(nowMillis: Long): String =
|
||||
SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date(nowMillis))
|
||||
|
||||
/** Whether Usage Access is granted. */
|
||||
fun hasUsageAccess(context: Context): Boolean {
|
||||
val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as? AppOpsManager
|
||||
?: return false
|
||||
return try {
|
||||
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
appOps.unsafeCheckOpNoThrow(
|
||||
AppOpsManager.OPSTR_GET_USAGE_STATS,
|
||||
Process.myUid(),
|
||||
context.packageName,
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
appOps.checkOpNoThrow(
|
||||
AppOpsManager.OPSTR_GET_USAGE_STATS,
|
||||
Process.myUid(),
|
||||
context.packageName,
|
||||
)
|
||||
}
|
||||
mode == AppOpsManager.MODE_ALLOWED
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "cannot read the usage app-op: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun startOfDay(nowMillis: Long): Long =
|
||||
Calendar.getInstance().apply {
|
||||
timeInMillis = nowMillis
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
}
|
||||
@@ -5,4 +5,5 @@
|
||||
<string name="accessibility_description">Screen Limits uses this service to see which app is in the
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package au.audasmedia.webrestrict.kids
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/** DNS parsing and NXDOMAIN building for the site filter (FR-18). Pure tests. */
|
||||
class DnsMessageTest {
|
||||
|
||||
@Test
|
||||
fun `parses the question of a standard query`() {
|
||||
val query = query("www.youtube.com")
|
||||
val question = DnsMessage.parseQuestion(query, query.size)
|
||||
assertNotNull(question)
|
||||
assertEquals("www.youtube.com", question!!.name)
|
||||
// 12 header + labels + root + qtype/qclass
|
||||
assertEquals(query.size, question.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lower-cases the question and rejects a response`() {
|
||||
val query = query("WWW.YouTube.COM")
|
||||
assertEquals("www.youtube.com", DnsMessage.parseQuestion(query, query.size)!!.name)
|
||||
|
||||
val response = query.copyOf()
|
||||
response[2] = (response[2].toInt() or 0x80).toByte()
|
||||
assertNull(DnsMessage.parseQuestion(response, response.size))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects a malformed packet`() {
|
||||
assertNull(DnsMessage.parseQuestion(ByteArray(4), 4))
|
||||
assertNull(DnsMessage.parseQuestion(query("youtube.com"), 14))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nxdomain echoes the question and sets the flags`() {
|
||||
val query = query("youtube.com")
|
||||
val question = DnsMessage.parseQuestion(query, query.size)!!
|
||||
val answer = DnsMessage.nxdomain(query, question.end)
|
||||
|
||||
assertEquals(question.end, answer.size)
|
||||
assertEquals(query[0], answer[0])
|
||||
assertEquals(query[1], answer[1])
|
||||
// QR = 1 and RCODE = 3.
|
||||
assertEquals(0x80, answer[2].toInt() and 0x80)
|
||||
assertEquals(0x03, answer[3].toInt() and 0x0F)
|
||||
// No answer, authority or additional records.
|
||||
assertEquals(0, IpPacket.u16(answer, 6))
|
||||
assertEquals(0, IpPacket.u16(answer, 8))
|
||||
assertEquals(0, IpPacket.u16(answer, 10))
|
||||
}
|
||||
|
||||
/** Builds one standard A query for [name]. */
|
||||
private fun query(name: String): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
out.write(byteArrayOf(0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0))
|
||||
for (label in name.split('.')) {
|
||||
out.write(label.length)
|
||||
out.write(label.toByteArray(Charsets.US_ASCII))
|
||||
}
|
||||
out.write(0)
|
||||
out.write(byteArrayOf(0x00, 0x01, 0x00, 0x01)) // QTYPE=A, QCLASS=IN
|
||||
return out.toByteArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package au.audasmedia.webrestrict.kids
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/** IPv4/UDP parsing and building for the DNS forwarder (FR-18). Pure tests. */
|
||||
class IpPacketTest {
|
||||
|
||||
private val clientIp = byteArrayOf(10, 111, 222, 1)
|
||||
private val dnsIp = byteArrayOf(10, 111, 222, 2)
|
||||
|
||||
@Test
|
||||
fun `builds a packet that parses back`() {
|
||||
val payload = byteArrayOf(1, 2, 3, 4, 5)
|
||||
val packet = IpPacket.buildUdp(clientIp, dnsIp, 40000, 53, payload)
|
||||
|
||||
val udp = IpPacket.parseUdp(packet, packet.size)
|
||||
assertEquals(17, packet[9].toInt() and 0xFF)
|
||||
assertEquals(20 + 8 + payload.size, IpPacket.u16(packet, 2))
|
||||
assertEquals(0, IpPacket.checksum(packet, 0, 20))
|
||||
assertEquals(40000, udp!!.srcPort)
|
||||
assertEquals(53, udp.dstPort)
|
||||
assertArrayEquals(clientIp, udp.srcIp)
|
||||
assertArrayEquals(dnsIp, udp.dstIp)
|
||||
assertArrayEquals(payload, udp.payload)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignores a non UDP packet`() {
|
||||
val packet = IpPacket.buildUdp(clientIp, dnsIp, 40000, 53, byteArrayOf(1))
|
||||
packet[9] = 6 // TCP
|
||||
assertNull(IpPacket.parseUdp(packet, packet.size))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignores a truncated packet`() {
|
||||
assertNull(IpPacket.parseUdp(ByteArray(10), 10))
|
||||
val packet = IpPacket.buildUdp(clientIp, dnsIp, 40000, 53, byteArrayOf(1, 2, 3))
|
||||
assertNull(IpPacket.parseUdp(packet, 10))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package au.audasmedia.webrestrict.kids
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Hostname matching for the site filter (FR-18, FR-4). No emulator needed. */
|
||||
class SiteFilterTest {
|
||||
|
||||
@Test
|
||||
fun `matches an exact host and a subdomain`() {
|
||||
assertEquals("youtube.com", SiteFilter.matchHost("youtube.com", listOf("youtube.com")))
|
||||
assertEquals("youtube.com", SiteFilter.matchHost("www.youtube.com", listOf("youtube.com")))
|
||||
assertNull(SiteFilter.matchHost("notyoutube.com", listOf("youtube.com")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `matching ignores case and a trailing dot`() {
|
||||
assertEquals("YouTube.com", SiteFilter.matchHost("WWW.youtube.com.", listOf("YouTube.com")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blocks a listed host while locked`() {
|
||||
val config = DeviceConfig(locked = true, block = listOf("youtube.com"))
|
||||
assertTrue(SiteFilter.isBlocked("www.youtube.com", config))
|
||||
assertFalse(SiteFilter.isBlocked("example.com", config))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not block while unlocked`() {
|
||||
val config = DeviceConfig(locked = false, block = listOf("youtube.com"))
|
||||
assertFalse(SiteFilter.isBlocked("www.youtube.com", config))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the allowlist wins during a block`() {
|
||||
val config = DeviceConfig(
|
||||
locked = true,
|
||||
block = listOf("youtube.com", "school.edu"),
|
||||
allow = listOf("school.edu"),
|
||||
)
|
||||
assertTrue(SiteFilter.isBlocked("www.youtube.com", config))
|
||||
assertFalse(SiteFilter.isBlocked("portal.school.edu", config))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an app identifier never matches a real host`() {
|
||||
val config = DeviceConfig(locked = true, block = listOf("com.google.android.youtube"))
|
||||
assertFalse(SiteFilter.isBlocked("www.youtube.com", config))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank or path-like entry is ignored`() {
|
||||
assertNull(SiteFilter.normalizeHost(""))
|
||||
assertNull(SiteFilter.normalizeHost(" "))
|
||||
assertNull(SiteFilter.normalizeHost("http://youtube.com/watch"))
|
||||
assertNull(SiteFilter.normalizeHost("a b.com"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package au.audasmedia.webrestrict.kids
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/** The pure part of the local telemetry roll-up (FR-7, FR-14). */
|
||||
class TelemetryStoreTest {
|
||||
|
||||
@Test
|
||||
fun `merge keeps the higher total per package`() {
|
||||
val existing = mapOf("com.a" to 100, "com.b" to 5)
|
||||
val samples = mapOf("com.a" to 40, "com.b" to 9, "com.c" to 3)
|
||||
val merged = TelemetryStore.merge(existing, samples)
|
||||
|
||||
assertEquals(100, merged["com.a"])
|
||||
assertEquals(9, merged["com.b"])
|
||||
assertEquals(3, merged["com.c"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `merge ignores blanks and negatives`() {
|
||||
val merged = TelemetryStore.merge(emptyMap(), mapOf("" to 10, "com.a" to -5, "com.b" to 0))
|
||||
assertEquals(emptyMap<String, Int>(), merged)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prune keeps the most recent days and drops the oldest`() {
|
||||
val days = linkedMapOf(
|
||||
"2026-10-01" to mapOf("com.a" to 1),
|
||||
"2026-10-02" to mapOf("com.a" to 2),
|
||||
"2026-10-03" to mapOf("com.a" to 3),
|
||||
)
|
||||
val pruned = TelemetryStore.prune(days, 2)
|
||||
assertEquals(setOf("2026-10-02", "2026-10-03"), pruned.keys)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user