diff --git a/README.md b/README.md index 724a74a..711630b 100644 --- a/README.md +++ b/README.md @@ -471,10 +471,37 @@ cache in `.gocache/` (gitignored), so nothing in the repository ends up owned by root. `make mqtt-verify` never touches the shared Mosquitto: it stages a scratch broker on `127.0.0.1:18883` and removes it afterwards. +## Kids' Android client (Phase 5.1) + +`android/` is the kids' phone app: a normal Gradle project with its own +`gradlew`. It blocks by **interruption** — an Accessibility Service sees the +foreground package and a full-screen overlay covers any app on the blocklist +while the device is locked (FR-16). There is **no Device Owner**, no factory +reset and no managed profile; the child can disable it, and the enforcement is +the tamper alert (FR-17). + +A tamper report rides the device's own status topic, because that is the only +topic its broker ACL may write: + +```json +{"online":true,"tamper":{"kind":"accessibility_disabled","detail":"the service was destroyed"}} +``` + +`internal/mqtt` parses it, `internal/alert` records a `tamper` alert and delivers +it through Apprise, so it reaches Sam. The client also reports a revoked overlay +permission and a force-stop on the next start. It keeps the last config on disk, +so a broker-less device keeps its rules (FR-14). + +The block path and the tamper alert are proven on the headless `pixel35` +emulator on `.27`; see `android/README.md` and `android/tools/emu-proof.sh`. + ## Layout ``` cmd/control/ service entry point (+ hash-password, mqtt-clients, rollup, keycode, alerts, usage) +android/ kids' Android client (Gradle, Kotlin; Accessibility + overlay) + app/src/main/java/… Accessibility Service, overlay, block rule, MQTT, tamper report + tools/emu-proof.sh headless emulator proof (block overlay + tamper) internal/config/ environment configuration internal/store/ SQLite open, migrations, audit, devices + state devices.go device registry, desired state, lock + grant expiry diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..18c45de --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,15 @@ +# Android / Gradle build output +build/ +.gradle/ +local.properties +*.apk +*.aab +*.keystore +*.jks +.cxx/ +captures/ + +# IDE +.idea/ +*.iml +.DS_Store diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..3d8556b --- /dev/null +++ b/android/README.md @@ -0,0 +1,63 @@ +# Kids' Android client — Phase 5.1 + +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`: + +```bash +ssh 192.168.20.27 '~/android/build.sh internet_use_control' +``` + +That pulls this repo from Gitea and runs `./gradlew assembleDebug`. It prints +`APK=`; the debug APK is `android/app/build/outputs/apk/debug/app-debug.apk`. + +## What it does + +| 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 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`. | +| 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). | +| Status screen | `MainActivity.kt` | What is blocked, until when, and the health of the enforcement. | + +**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 + +The device's broker ACL only lets it write its own `status` topic, so the tamper +report rides there: + +```json +{"online":true,"tamper":{"kind":"accessibility_disabled","detail":"the service was destroyed","at":"2026-10-13T08:00:00Z"}} +``` + +`internal/mqtt` parses it, `internal/alert` records a `tamper` alert and delivers +it through Apprise, and `cmd/control` wires the two together. + +## Test on the headless emulator + +```bash +ssh 192.168.20.27 '~/android/emu.sh start' +ssh 192.168.20.27 '~/android/emu.sh wait' +ssh 192.168.20.27 '~/android/emu.sh install /tmp/webrestrict-pre/android/app/build/outputs/apk/debug/app-debug.apk' + +PKG=au.audasmedia.webrestrict.kids +adb shell appops set $PKG SYSTEM_ALERT_WINDOW allow +adb shell settings put secure enabled_accessibility_services $PKG/$PKG.BlockAccessibilityService +adb shell settings put secure accessibility_enabled 1 + +# Open a blocked app (the local default blocks Settings) and screenshot. +adb shell am start -a android.settings.SETTINGS +sleep 3 +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' +``` diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..4daa71a --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,71 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "au.audasmedia.webrestrict.kids" + compileSdk = 35 + + defaultConfig { + applicationId = "au.audasmedia.webrestrict.kids" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0-phase5.1" + + // The emulator default: a device config with the Settings app blocked. + // A real device receives the retained config over MQTT and overwrites + // this, but a broker-less device must still block (FR-14). + buildConfigField("String", "DEFAULT_DEVICE_TOPIC", "\"restrict/harry_phone_1/config\"") + buildConfigField("String", "DEFAULT_DEVICE_NAME", "\"Harry's phone\"") + buildConfigField("String", "DEFAULT_BROKER_HOST", "\"192.168.20.13\"") + buildConfigField("int", "DEFAULT_BROKER_PORT", "8883") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + debug { + // The headless emulator proof installs the debug build. Keep the + // application id identical to release so the adb commands that + // enable the Accessibility Service stay simple. + versionNameSuffix = "-debug" + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + buildConfig = true + viewBinding = false + } + packaging { + resources.excludes += setOf("META-INF/*.kotlin_module") + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.appcompat:appcompat:1.7.0") + + // MQTT over TLS on the 8883 plane (AGENTS.md §10 rule 6). The Java client is + // pure JVM, so it also runs under the unit tests. + implementation("org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5") + + // JSON is identical on Android and on the JVM, so Config parsing is unit + // tested without Robolectric. + implementation("com.google.code.gson:gson:2.11.0") + + testImplementation("junit:junit:4.13.2") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..e7dfae9 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,3 @@ +# Keep the release build the default rules only; nothing here needs removal. +-keep class org.eclipse.paho.client.mqttv3.** { *; } +-dontwarn org.eclipse.paho.client.mqttv3.** diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1f095d5 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/au/audasmedia/webrestrict/kids/AppPrefs.kt b/android/app/src/main/java/au/audasmedia/webrestrict/kids/AppPrefs.kt new file mode 100644 index 0000000..71461eb --- /dev/null +++ b/android/app/src/main/java/au/audasmedia/webrestrict/kids/AppPrefs.kt @@ -0,0 +1,154 @@ +package au.audasmedia.webrestrict.kids + +import android.content.Context +import android.content.SharedPreferences +import android.provider.Settings +import android.util.Log + +/** + * Small, synchronous preferences store. Everything here is local state, never a + * second source of truth: the Control Service still owns the rules. + */ +class AppPrefs(context: Context) { + private val prefs: SharedPreferences = + context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE) + + var deviceTopic: String + get() = prefs.getString(KEY_TOPIC, BuildConfig.DEFAULT_DEVICE_TOPIC)!! + set(value) = prefs.edit().putString(KEY_TOPIC, value).apply() + + var deviceName: String + get() = prefs.getString(KEY_DEVICE_NAME, BuildConfig.DEFAULT_DEVICE_NAME)!! + set(value) = prefs.edit().putString(KEY_DEVICE_NAME, value).apply() + + var brokerHost: String + get() = prefs.getString(KEY_BROKER_HOST, BuildConfig.DEFAULT_BROKER_HOST)!! + set(value) = prefs.edit().putString(KEY_BROKER_HOST, value).apply() + + var brokerPort: Int + get() = prefs.getInt(KEY_BROKER_PORT, BuildConfig.DEFAULT_BROKER_PORT) + set(value) = prefs.edit().putInt(KEY_BROKER_PORT, value).apply() + + /** The MQTT account. The broker ACL names it after the topic token. */ + var mqttUsername: String + get() = prefs.getString(KEY_MQTT_USER, topicToken())!! + set(value) = prefs.edit().putString(KEY_MQTT_USER, value).apply() + + var mqttPassword: String + get() = prefs.getString(KEY_MQTT_PASSWORD, "")!! + set(value) = prefs.edit().putString(KEY_MQTT_PASSWORD, value).apply() + + /** Optional asset file holding the lab CA certificate, e.g. "lab-ca.pem". */ + var caAsset: String + get() = prefs.getString(KEY_CA_ASSET, "")!! + set(value) = prefs.edit().putString(KEY_CA_ASSET, 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) + set(value) = prefs.edit().putString(KEY_CONFIG, value).apply() + + /** The service writes this every watchdog tick, so a force-stop leaves a gap. */ + var heartbeatAt: Long + get() = prefs.getLong(KEY_HEARTBEAT, 0L) + set(value) = prefs.edit().putLong(KEY_HEARTBEAT, value).apply() + + var heartbeatBoot: Int + get() = prefs.getInt(KEY_HEARTBEAT_BOOT, -1) + set(value) = prefs.edit().putInt(KEY_HEARTBEAT_BOOT, value).apply() + + var armed: Boolean + get() = prefs.getBoolean(KEY_ARMED, false) + set(value) = prefs.edit().putBoolean(KEY_ARMED, value).apply() + + var lastTamperKind: String + get() = prefs.getString(KEY_TAMPER_KIND, "")!! + set(value) = prefs.edit().putString(KEY_TAMPER_KIND, value).apply() + + var lastTamperAt: Long + get() = prefs.getLong(KEY_TAMPER_AT, 0L) + set(value) = prefs.edit().putLong(KEY_TAMPER_AT, value).apply() + + var lastTamperDetail: String + get() = prefs.getString(KEY_TAMPER_DETAIL, "")!! + set(value) = prefs.edit().putString(KEY_TAMPER_DETAIL, value).apply() + + fun statusTopic(): String = deviceTopic.removeSuffix("/config") + "/status" + + /** The middle topic level, which is also the broker account name. */ + fun topicToken(): String = + deviceTopic.removeSuffix("/config").substringAfterLast('/') + + fun bootCount(): Int = + try { + Settings.Global.getInt( + appContext.contentResolver, + Settings.Global.BOOT_COUNT, + -1, + ) + } catch (e: Exception) { + Log.w(TAG, "cannot read boot count: ${e.message}") + -1 + } + + // A Context is needed for bootCount; hold the application context. + lateinit var appContext: Context + private set + + init { + appContext = context.applicationContext + } + + companion object { + private const val TAG = "WebRestrictPrefs" + private const val FILE = "webrestrict_kids" + private const val KEY_TOPIC = "device_topic" + private const val KEY_DEVICE_NAME = "device_name" + private const val KEY_BROKER_HOST = "broker_host" + private const val KEY_BROKER_PORT = "broker_port" + 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_CONFIG = "config_json" + private const val KEY_HEARTBEAT = "heartbeat_at" + private const val KEY_HEARTBEAT_BOOT = "heartbeat_boot" + private const val KEY_ARMED = "armed" + private const val KEY_TAMPER_KIND = "tamper_kind" + private const val KEY_TAMPER_AT = "tamper_at" + private const val KEY_TAMPER_DETAIL = "tamper_detail" + } +} + +/** + * Loads and saves the device config. A config that cannot be parsed is ignored, + * so a broker bug can never leave the device with no rules (FR-14). + */ +class ConfigStore(context: Context) { + private val prefs = AppPrefs(context) + + /** Returns the last-known config, seeding the local default if there is none. */ + fun load(): DeviceConfig { + val json = prefs.configJson ?: return DeviceConfig.default().also { save(it) } + return try { + DeviceConfig.fromJson(json) + } catch (e: Exception) { + Log.w(TAG, "stored config is unreadable, keeping the default: ${e.message}") + DeviceConfig.default() + } + } + + fun save(config: DeviceConfig) { + prefs.configJson = config.toJson() + } + + /** Writes the local default only when nothing is stored yet. */ + fun seedIfEmpty() { + if (prefs.configJson == null) { + save(DeviceConfig.default()) + } + } + + companion object { + private const val TAG = "WebRestrictConfig" + } +} diff --git a/android/app/src/main/java/au/audasmedia/webrestrict/kids/BlockAccessibilityService.kt b/android/app/src/main/java/au/audasmedia/webrestrict/kids/BlockAccessibilityService.kt new file mode 100644 index 0000000..5fb7757 --- /dev/null +++ b/android/app/src/main/java/au/audasmedia/webrestrict/kids/BlockAccessibilityService.kt @@ -0,0 +1,141 @@ +package au.audasmedia.webrestrict.kids + +import android.accessibilityservice.AccessibilityService +import android.content.Intent +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.util.Log +import android.view.accessibility.AccessibilityEvent + +/** + * FR-16 + FR-17. Watches the foreground package and interposes the block + * overlay, then reports its own removal. + * + * This is deliberately a normal Accessibility Service: no Device Owner, no + * managed profile, no factory reset. A child can turn it off, and when they do + * the tamper reporter tells the Control Service (FR-17). The service never + * edits its own rules; it obeys the config the Control Service publishes + * (AGENTS.md §10 rule 7). + */ +class BlockAccessibilityService : AccessibilityService() { + + private lateinit var prefs: AppPrefs + private lateinit var configs: ConfigStore + private lateinit var overlay: OverlayController + + private val watchdog = Handler(Looper.getMainLooper()) + private var foregroundPackage: String? = null + private var overlayReported = false + + private val watchdogTick = object : Runnable { + override fun run() { + beat() + checkOverlayPermission() + watchdog.postDelayed(this, WATCHDOG_MS) + } + } + + override fun onServiceConnected() { + super.onServiceConnected() + instance = this + prefs = AppPrefs(this) + configs = ConfigStore(this) + overlay = OverlayController(this) + + // Arm force-stop detection: from now on, a heartbeat gap means a kill. + prefs.armed = true + prefs.heartbeatAt = System.currentTimeMillis() + prefs.heartbeatBoot = prefs.bootCount() + + MqttController.setConfigListener { config -> + Log.i(TAG, "re-evaluating after config update locked=${config.locked}") + evaluate(foregroundPackage) + } + MqttController.start(this) + MqttController.publishStatus(this, foregroundPackage) + + watchdog.removeCallbacks(watchdogTick) + watchdog.post(watchdogTick) + Log.i(TAG, "accessibility service connected") + } + + override fun onAccessibilityEvent(event: AccessibilityEvent?) { + if (event == null) return + // Only a window state change means "this app is now in front". The + // windows-changed event fires for every window in the stack, including + // transient ones, and would flap the overlay off. + if (event.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return + val pkg = event.packageName?.toString() ?: return + // Our own window (the overlay) is not a foreground app to judge. + if (pkg == packageName) return + // System UI and the system itself are not the app the kid chose. + if (pkg == "com.android.systemui" || pkg == "android") return + if (pkg != foregroundPackage) { + foregroundPackage = pkg + MqttController.publishStatus(this, pkg) + } + evaluate(pkg) + } + + /** Applies the block decision for one package. */ + private fun evaluate(pkg: String?) { + val config = configs.load() + val decision = BlockEngine.decide(pkg, config, packageName) + Log.d(TAG, "evaluate pkg=$pkg locked=${config.locked} blocked=${decision.blocked}") + if (decision.blocked) { + overlay.show(decision) + } else { + overlay.hide() + } + } + + override fun onInterrupt() { + // The system pauses the service (for example another service took over). + // Not a tamper by itself, but worth a line in the log. + Log.w(TAG, "accessibility service interrupted; keeping the last config") + } + + override fun onUnbind(intent: Intent?): Boolean { + TamperReporter.report(this, "accessibility_disabled", "the service was unbound") + return super.onUnbind(intent) + } + + override fun onDestroy() { + TamperReporter.report(this, "accessibility_disabled", "the service was destroyed") + watchdog.removeCallbacks(watchdogTick) + overlay.hide() + instance = null + Log.i(TAG, "accessibility service destroyed") + super.onDestroy() + } + + private fun beat() { + prefs.heartbeatAt = System.currentTimeMillis() + prefs.heartbeatBoot = prefs.bootCount() + } + + /** + * The overlay permission can be revoked while the service stays alive. That + * is tampering with a permission this client depends on, so report it once + * per transition (FR-17). + */ + private fun checkOverlayPermission() { + val granted = Settings.canDrawOverlays(this) + if (!granted && !overlayReported) { + overlayReported = true + TamperReporter.report(this, "overlay_revoked", "SYSTEM_ALERT_WINDOW is no longer granted") + } else if (granted) { + overlayReported = false + } + } + + companion object { + private const val TAG = "WebRestrictService" + private const val WATCHDOG_MS = 15_000L + + /** The live service, or null. MainActivity reads this for its status screen. */ + @Volatile + var instance: BlockAccessibilityService? = null + } +} diff --git a/android/app/src/main/java/au/audasmedia/webrestrict/kids/BlockEngine.kt b/android/app/src/main/java/au/audasmedia/webrestrict/kids/BlockEngine.kt new file mode 100644 index 0000000..6e27d05 --- /dev/null +++ b/android/app/src/main/java/au/audasmedia/webrestrict/kids/BlockEngine.kt @@ -0,0 +1,47 @@ +package au.audasmedia.webrestrict.kids + +/** + * The decision for one foreground package. + * + * [value] is the blocklist entry that matched, so the overlay can name what is + * blocked. [reason] and [until] come from the config, so the overlay can say + * why and until when. + */ +data class BlockDecision( + val blocked: Boolean, + val value: String? = null, + val reason: String? = null, + val until: String? = null, +) { + companion object { + val Allowed = BlockDecision(blocked = false) + } +} + +/** + * Pure block logic, unit tested without an emulator. + * + * Blocked means: the config is locked, the foreground package is on the + * blocklist, and it is not on the allowlist. The client never widens or + * narrows the rules itself (AGENTS.md §10 rule 7). + */ +object BlockEngine { + fun decide(packageName: String?, config: DeviceConfig, selfPackage: String): BlockDecision { + if (packageName.isNullOrBlank()) return BlockDecision.Allowed + // Never block ourselves: the overlay belongs to this package, and the + // status screen must stay reachable. + if (packageName == selfPackage) return BlockDecision.Allowed + // The allowlist is honoured during a block (FR-4). + if (config.allow.contains(packageName)) return BlockDecision.Allowed + if (!config.locked) return BlockDecision.Allowed + if (config.block.contains(packageName)) { + return BlockDecision( + blocked = true, + value = packageName, + reason = config.reason, + until = config.until, + ) + } + return BlockDecision.Allowed + } +} diff --git a/android/app/src/main/java/au/audasmedia/webrestrict/kids/BootReceiver.kt b/android/app/src/main/java/au/audasmedia/webrestrict/kids/BootReceiver.kt new file mode 100644 index 0000000..8bfed1e --- /dev/null +++ b/android/app/src/main/java/au/audasmedia/webrestrict/kids/BootReceiver.kt @@ -0,0 +1,27 @@ +package au.audasmedia.webrestrict.kids + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log + +/** + * After a reboot or an app update, reconnect to the broker so the retained + * config arrives at once (FR-5, FR-14). The system owns the Accessibility + * Service, so this receiver does not try to start it. + */ +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (context == null) return + Log.i("WebRestrictBoot", "boot/update event: ${intent?.action}") + try { + // A reboot is not a force-stop; reset the heartbeat so the next + // app start does not mistake it for one. + val prefs = AppPrefs(context) + prefs.heartbeatAt = 0L + MqttController.start(context) + } catch (e: Exception) { + Log.w("WebRestrictBoot", "post-boot start failed: ${e.message}") + } + } +} diff --git a/android/app/src/main/java/au/audasmedia/webrestrict/kids/DeviceConfig.kt b/android/app/src/main/java/au/audasmedia/webrestrict/kids/DeviceConfig.kt new file mode 100644 index 0000000..d60c5d5 --- /dev/null +++ b/android/app/src/main/java/au/audasmedia/webrestrict/kids/DeviceConfig.kt @@ -0,0 +1,61 @@ +package au.audasmedia.webrestrict.kids + +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName + +/** + * The desired state for one device, exactly as the Control Service publishes it + * retained on `restrict//config` (TRANSPORT_DESIGN.md, Phase 3). + * + * [locked] and [until] are the effective state. While [locked] is true the + * client blocks every [block] value except the [allow] values (FR-4). Unknown + * fields are ignored, so the payload may grow without a client update. + */ +data class DeviceConfig( + val v: Int = 1, + @SerializedName("device_id") val deviceId: Long = 0, + val name: String = "", + val platform: String = "android", + val locked: Boolean = false, + val until: String? = null, + val allow: List = emptyList(), + val block: List = emptyList(), + val reason: String? = null, + @SerializedName("grant_until") val grantUntil: String? = null, + @SerializedName("generated_at") val generatedAt: String? = null, +) { + /** The effective [block] list minus every [allow] value. */ + fun effectiveBlock(): List = block.filterNot { it in allow } + + fun toJson(): String = gson.toJson(this) + + companion object { + private val gson = Gson() + + /** Parses a config payload. Throws on malformed JSON so the caller can keep the last one. */ + fun fromJson(json: String): DeviceConfig = + gson.fromJson(json, DeviceConfig::class.java) ?: default() + + /** + * The local fallback used until the broker delivers a retained config + * (FR-14 "keep last state", and the accepted emulator default). It + * blocks the Settings app so the block path is visible on a stock AVD. + */ + fun default(): DeviceConfig = DeviceConfig( + v = 1, + deviceId = 0, + name = BuildConfig.DEFAULT_DEVICE_NAME, + platform = "android", + locked = true, + until = null, + reason = "schedule", + allow = emptyList(), + block = listOf( + "com.android.settings", + "com.google.android.youtube", + "com.android.chrome", + ), + generatedAt = null, + ) + } +} 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 new file mode 100644 index 0000000..7687b6f --- /dev/null +++ b/android/app/src/main/java/au/audasmedia/webrestrict/kids/KidsApp.kt @@ -0,0 +1,26 @@ +package au.audasmedia.webrestrict.kids + +import android.app.Application +import android.util.Log + +/** + * The client process. It seeds the local default config and checks whether the + * previous run was killed, before the Accessibility Service even connects. + */ +class KidsApp : Application() { + override fun onCreate() { + super.onCreate() + try { + ConfigStore(this).seedIfEmpty() + TamperReporter.detectForceStop(this) + MqttController.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}") + } + } + + companion object { + private const val TAG = "WebRestrictApp" + } +} 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 new file mode 100644 index 0000000..4939f2b --- /dev/null +++ b/android/app/src/main/java/au/audasmedia/webrestrict/kids/MainActivity.kt @@ -0,0 +1,189 @@ +package au.audasmedia.webrestrict.kids + +import android.content.ComponentName +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import android.text.InputType +import android.util.TypedValue +import android.view.Gravity +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * The device status screen for Sam (and a technical view for the kid). It shows + * what is blocked, until when, and the health of the two things the client + * depends on: the Accessibility Service and the overlay permission. + * + * It never edits the rules; those come from the Control Service. + */ +class MainActivity : AppCompatActivity() { + + private lateinit var container: LinearLayout + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + container = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(20), dp(20), dp(20), dp(20)) + } + setContentView(ScrollView(this).apply { addView(container) }) + refresh() + } + + override fun onResume() { + super.onResume() + refresh() + } + + private fun refresh() { + container.removeAllViews() + val prefs = AppPrefs(this) + val config = ConfigStore(this).load() + + title("Screen Limits") + subtitle("This phone obeys the family device rules.") + + section("Device") + row("Name", prefs.deviceName) + row("Topic", prefs.deviceTopic) + row("Broker", "${prefs.brokerHost}:${prefs.brokerPort} (TLS)") + row("Broker link", if (MqttController.isConnected()) "connected" else "offline (keeping last config)") + + section("Now") + row("State", if (config.locked) "blocked" else "allowed") + row("Reason", config.reason ?: "—") + row("Until", config.until ?: "no end time") + row("Blocked apps", config.effectiveBlock().joinToString(", ").ifBlank { "none" }) + row("Allowed during a block", config.allow.joinToString(", ").ifBlank { "none" }) + + section("Enforcement") + val accessibility = isAccessibilityEnabled() + row("Accessibility Service", if (accessibility) "enabled" else "DISABLED — tamper reported") + row("Overlay permission", if (Settings.canDrawOverlays(this)) "granted" else "REVOKED — tamper reported") + val tamperAt = prefs.lastTamperAt + row( + "Last tamper", + if (tamperAt <= 0) "none" + else "${prefs.lastTamperKind} at ${time(tamperAt)} (${prefs.lastTamperDetail})", + ) + + section("Actions") + button("Open Accessibility settings") { + startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) + } + button("Grant overlay permission") { + startActivity( + Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:$packageName"), + ), + ) + } + button("Reload the local default config") { + ConfigStore(this).save(DeviceConfig.default()) + refresh() + } + button("Report a test tamper") { + TamperReporter.report(this, "accessibility_disabled", "manual test from the status screen") + refresh() + } + + section("Edit broker settings (lab)") + field("Broker host", prefs.brokerHost) { prefs.brokerHost = it; MqttController.stop(); MqttController.start(this) } + field("Broker port", prefs.brokerPort.toString()) { + it.toIntOrNull()?.let { port -> prefs.brokerPort = port } + } + field("Device config topic", prefs.deviceTopic) { prefs.deviceTopic = it } + field("MQTT username", prefs.mqttUsername) { prefs.mqttUsername = it } + field("MQTT password", prefs.mqttPassword) { prefs.mqttPassword = it; MqttController.stop(); MqttController.start(this) } + } + + private fun isAccessibilityEnabled(): Boolean { + val expected = ComponentName(this, BlockAccessibilityService::class.java) + val enabled = Settings.Secure.getString( + contentResolver, + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, + ) ?: return false + return enabled.split(':').any { entry -> + val cn = ComponentName.unflattenFromString(entry) ?: return@any false + cn.packageName == expected.packageName && cn.className == expected.className + } + } + + // --- tiny view builders ------------------------------------------------- + + private fun title(value: String) { + container.addView(TextView(this).apply { + text = value + setTextSize(TypedValue.COMPLEX_UNIT_SP, 26f) + setTextColor(Color.parseColor("#111418")) + setPadding(0, 0, 0, dp(4)) + }) + } + + private fun subtitle(value: String) { + container.addView(TextView(this).apply { + text = value + setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) + setTextColor(Color.parseColor("#6b7280")) + setPadding(0, 0, 0, dp(12)) + }) + } + + private fun section(value: String) { + container.addView(TextView(this).apply { + text = value + setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f) + setTextColor(Color.parseColor("#0075de")) + setPadding(0, dp(18), 0, dp(6)) + }) + } + + private fun row(label: String, value: String) { + container.addView(TextView(this).apply { + text = "$label: $value" + setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f) + setTextColor(Color.parseColor("#111418")) + setPadding(0, dp(3), 0, dp(3)) + }) + } + + private fun button(label: String, action: () -> Unit) { + container.addView(Button(this).apply { + text = label + gravity = Gravity.START + setOnClickListener { action() } + }) + } + + private fun field(label: String, value: String, onChange: (String) -> Unit) { + container.addView(TextView(this).apply { + text = label + setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f) + setTextColor(Color.parseColor("#6b7280")) + setPadding(0, dp(8), 0, 0) + }) + val input = EditText(this).apply { + setText(value) + inputType = InputType.TYPE_CLASS_TEXT + } + input.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) onChange(input.text.toString()) } + container.addView(input) + } + + private fun time(millis: Long): String = + SimpleDateFormat("HH:mm:ss", Locale.US).format(Date(millis)) + + private fun dp(value: Int): Int = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), resources.displayMetrics).toInt() +} 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 new file mode 100644 index 0000000..899692e --- /dev/null +++ b/android/app/src/main/java/au/audasmedia/webrestrict/kids/MqttController.kt @@ -0,0 +1,276 @@ +package au.audasmedia.webrestrict.kids + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.google.gson.Gson +import org.eclipse.paho.client.mqttv3.IMqttActionListener +import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken +import org.eclipse.paho.client.mqttv3.IMqttToken +import org.eclipse.paho.client.mqttv3.MqttAsyncClient +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.InputStream +import java.security.KeyStore +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.Executors +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManagerFactory + +/** + * The Phase 3 transport, on the device side (AGENTS.md §10 rule 6). + * + * Subscribes to the retained `restrict//config` topic over the 8883 TLS + * plane with the device's own credentials, and publishes presence plus the + * tamper report on `restrict//status`. It never publishes config: the + * broker ACL would drop it, and the client only obeys (rule 7). + * + * A broker that is down is not fatal. The last config stays on disk (FR-14) and + * the connect loop backs off and retries. This is a deliberate simplification of + * the real client; the emulator proof never reaches a broker. + */ +object MqttController { + private const val TAG = "WebRestrictMqtt" + private const val QOS = 1 + private const val RETRY_MS = 30_000L + + private val gson = Gson() + private val executor = Executors.newSingleThreadExecutor { r -> + Thread(r, "webrestrict-mqtt").apply { isDaemon = true } + } + private val main = Handler(Looper.getMainLooper()) + + @Volatile + private var client: MqttAsyncClient? = null + + @Volatile + private var connected = false + + @Volatile + private var lastError: String? = null + + @Volatile + private var configListener: ((DeviceConfig) -> Unit)? = null + + fun isConnected(): Boolean = connected + + fun lastError(): String? = lastError + + /** Registers the callback for a config that arrives over MQTT. */ + fun setConfigListener(listener: (DeviceConfig) -> Unit) { + configListener = listener + } + + /** Starts the link once. Safe to call from Application, Activity and the service. */ + fun start(context: Context) { + if (client != null) return + val app = context.applicationContext + executor.execute { + if (client != null) return@execute + try { + val prefs = AppPrefs(app) + val uri = "ssl://${prefs.brokerHost}:${prefs.brokerPort}" + val c = MqttAsyncClient(uri, "kid-${prefs.topicToken()}", MemoryPersistence()) + c.setCallback(callback(app, c, prefs)) + client = c + connect(app, c, prefs) + } catch (e: Exception) { + lastError = e.message + Log.w(TAG, "mqtt init failed: ${e.message}") + scheduleRetry(app) + } + } + } + + /** Disconnects and forgets the client (used by tests and a config change). */ + fun stop() { + val c = client ?: return + client = null + connected = false + executor.execute { + try { + c.disconnectForcibly(0, 0) + c.close() + } catch (e: Exception) { + Log.w(TAG, "mqtt close: ${e.message}") + } + } + } + + private fun connect(app: Context, c: MqttAsyncClient, prefs: AppPrefs) { + val options = MqttConnectOptions().apply { + isAutomaticReconnect = true + isCleanSession = true + keepAliveInterval = 30 + connectionTimeout = 10 + userName = prefs.mqttUsername.ifBlank { null } + if (prefs.mqttPassword.isNotEmpty()) password = prefs.mqttPassword.toCharArray() + // Presence: the broker publishes this the moment the link drops + // without a clean DISCONNECT (FR-6). + setWill(prefs.statusTopic(), ONLINE_WILL, QOS, true) + } + tlsSocketFactory(app, prefs)?.let { options.socketFactory = it } + + try { + c.connect(options, null, object : IMqttActionListener { + override fun onSuccess(token: IMqttToken?) { + connected = true + lastError = null + Log.i(TAG, "mqtt connected to ${prefs.brokerHost}:${prefs.brokerPort}") + subscribe(c, prefs) + publishStatus(app, null) + } + + override fun onFailure(token: IMqttToken?, exception: Throwable?) { + connected = false + lastError = exception?.message + Log.w(TAG, "mqtt connect failed: ${exception?.message}") + scheduleRetry(app) + } + }) + } catch (e: Exception) { + lastError = e.message + Log.w(TAG, "mqtt connect: ${e.message}") + scheduleRetry(app) + } + } + + private fun subscribe(c: MqttAsyncClient, prefs: AppPrefs) { + try { + c.subscribe(prefs.deviceTopic, QOS) + Log.i(TAG, "subscribed to ${prefs.deviceTopic}") + } catch (e: Exception) { + Log.w(TAG, "mqtt subscribe: ${e.message}") + } + } + + private fun callback(app: Context, c: MqttAsyncClient, prefs: AppPrefs) = + object : MqttCallbackExtended { + override fun connectComplete(reconnect: Boolean, serverURI: String?) { + connected = true + lastError = null + Log.i(TAG, "mqtt connectComplete reconnect=$reconnect") + subscribe(c, prefs) + publishStatus(app, null) + } + + override fun connectionLost(cause: Throwable?) { + connected = false + lastError = cause?.message + Log.w(TAG, "mqtt connection lost: ${cause?.message}") + } + + override fun messageArrived(topic: String?, message: MqttMessage?) { + if (topic == null || message == null) return + if (!topic.endsWith("/config")) return + val json = String(message.payload, Charsets.UTF_8) + try { + val config = DeviceConfig.fromJson(json) + ConfigStore(app).save(config) + Log.i(TAG, "config received locked=${config.locked} until=${config.until}") + main.post { configListener?.invoke(config) } + } catch (e: Exception) { + Log.w(TAG, "config rejected, keeping the last one: ${e.message}") + } + } + + override fun deliveryComplete(token: IMqttDeliveryToken?) {} + } + + /** Publishes the presence status, optionally naming the foreground app. */ + fun publishStatus(context: Context, foregroundApp: String?) { + val prefs = AppPrefs(context) + val payload = StatusPayload( + online = true, + app = foregroundApp, + locked = ConfigStore(context).load().locked, + at = isoNow(), + ) + publish(prefs.statusTopic(), payload, retained = true) + } + + /** Publishes the tamper report on the same status topic (FR-17). */ + fun publishTamper(context: Context, kind: String, detail: String) { + val prefs = AppPrefs(context) + val payload = StatusPayload( + online = true, + at = isoNow(), + tamper = TamperPayload(kind = kind, detail = detail, at = isoNow()), + ) + publish(prefs.statusTopic(), payload, retained = true) + } + + private fun publish(topic: String, payload: Any, retained: Boolean) { + val c = client ?: return + if (!connected) return + try { + val message = MqttMessage(gson.toJson(payload).toByteArray(Charsets.UTF_8)).apply { + qos = QOS + isRetained = retained + } + c.publish(topic, message) + } catch (e: Exception) { + Log.w(TAG, "mqtt publish to $topic: ${e.message}") + } + } + + /** Retries the initial connection after a failure. A successful connect is + * re-handled by Paho's automatic reconnect; an initial failure is not. */ + private fun scheduleRetry(context: Context) { + val app = context.applicationContext + main.postDelayed({ + executor.execute { + val c = client ?: return@execute + connect(app, c, AppPrefs(app)) + } + }, RETRY_MS) + } + + /** + * 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. + */ + private fun tlsSocketFactory(context: Context, prefs: AppPrefs): SSLSocketFactory? { + if (prefs.caAsset.isBlank()) 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) } + } + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + 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}") + null + } + } + + private fun readCertificates(input: InputStream): List = + CertificateFactory.getInstance("X.509").generateCertificates(input).map { it as X509Certificate } + + private fun isoNow(): String = + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).format(Date()) + + private val ONLINE_WILL = "{\"online\":false}".toByteArray(Charsets.UTF_8) + + private data class TamperPayload(val kind: String, val detail: String?, val at: String) + + private data class StatusPayload( + val online: Boolean, + val app: String? = null, + val locked: Boolean? = null, + val at: String, + val tamper: TamperPayload? = null, + ) +} diff --git a/android/app/src/main/java/au/audasmedia/webrestrict/kids/OverlayController.kt b/android/app/src/main/java/au/audasmedia/webrestrict/kids/OverlayController.kt new file mode 100644 index 0000000..9950e1d --- /dev/null +++ b/android/app/src/main/java/au/audasmedia/webrestrict/kids/OverlayController.kt @@ -0,0 +1,179 @@ +package au.audasmedia.webrestrict.kids + +import android.content.Context +import android.graphics.Color +import android.graphics.PixelFormat +import android.graphics.Typeface +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.view.WindowManager +import android.widget.LinearLayout +import android.widget.TextView +import java.text.SimpleDateFormat +import java.time.Instant +import java.time.ZoneId +import java.util.Date +import java.util.Locale + +/** + * The full-screen block overlay (FR-16). It is an ordinary + * `TYPE_APPLICATION_OVERLAY` window, so it needs the overlay permission and + * covers the blocked app completely. It only blocks; it never changes a rule. + * + * Every call is posted to the main looper because window changes must run there + * and the caller may be on the MQTT thread. + */ +class OverlayController(private val context: Context) { + private val windowManager = + context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + private val main = Handler(Looper.getMainLooper()) + + private var view: View? = null + + fun show(decision: BlockDecision) { + val label = appLabel(decision.value) + main.post { showOnMain(decision, label) } + } + + fun hide() { + main.post { hideOnMain() } + } + + fun isShowing(): Boolean = view != null + + private fun showOnMain(decision: BlockDecision, label: String) { + if (view != null) return + val root = buildView(decision, label) + // TYPE_ACCESSIBILITY_OVERLAY is deliberately preferred over + // TYPE_APPLICATION_OVERLAY. Android force-hides non-system overlays + // while a window with HIDE_NON_SYSTEM_OVERLAY_WINDOWS is on top (some + // Settings screens and system dialogs set it), which would leave a + // blocked app usable. An accessibility overlay is trusted, needs no + // SYSTEM_ALERT_WINDOW, and cannot be hidden that way. The overlay + // permission is still required as a setup grant and its revocation is + // reported (FR-17). + val type = WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY + val params = WindowManager.LayoutParams( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT, + type, + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN, + PixelFormat.TRANSLUCENT, + ).apply { + gravity = Gravity.TOP or Gravity.START + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + } + } + try { + windowManager.addView(root, params) + view = root + Log.i(TAG, "block overlay shown for ${decision.value} (type=$type until=${decision.until})") + } catch (e: Exception) { + Log.w(TAG, "cannot add accessibility overlay: ${e.message}") + } + } + + private fun hideOnMain() { + val v = view ?: return + view = null + try { + windowManager.removeView(v) + Log.i(TAG, "block overlay hidden") + } catch (e: Exception) { + Log.w(TAG, "cannot remove overlay: ${e.message}") + } + } + + private fun buildView(decision: BlockDecision, label: String): View { + val root = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + setBackgroundColor(Color.parseColor("#F2101418")) + setPadding(dp(32), dp(32), dp(32), dp(32)) + isClickable = true + isFocusable = true + setOnClickListener { /* consume taps on the blocked app */ } + } + + root.addView(text("Blocked", 34f, Color.WHITE, Typeface.BOLD)) + root.addView(text(label, 22f, Color.parseColor("#9fd0ff"), Typeface.NORMAL).withTopMargin()) + root.addView(text(reasonLine(decision.reason), 16f, Color.parseColor("#d8dde3"), Typeface.NORMAL).withTopMargin()) + decision.until?.let { until -> + humanUntil(until)?.let { + root.addView(text("Until $it", 16f, Color.parseColor("#d8dde3"), Typeface.NORMAL).withTopMargin()) + } + } + root.addView( + text( + "Ask a parent if you need more time.", + 14f, + Color.parseColor("#8b949e"), + Typeface.NORMAL, + ).withTopMargin(), + ) + return root + } + + private fun text(value: String, sizeSp: Float, color: Int, style: Int): TextView = + TextView(context).apply { + text = value + setTextColor(color) + setTextSize(TypedValue.COMPLEX_UNIT_SP, sizeSp) + typeface = Typeface.create("sans-serif", style) + gravity = Gravity.CENTER + } + + private fun TextView.withTopMargin(): TextView = apply { + (layoutParams as? LinearLayout.LayoutParams)?.let { it.topMargin = dp(12) } + ?: run { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(12) } + } + } + + private fun reasonLine(reason: String?): String = when (reason) { + "schedule" -> "This app is blocked by the family schedule." + "manual" -> "A parent locked this app." + "grant" -> "This app is blocked again." + else -> "This app is blocked." + } + + private fun humanUntil(until: String?): String? { + if (until.isNullOrBlank()) return null + val millis = try { + Instant.parse(until).toEpochMilli() + } catch (e: Exception) { + // Anything the service sends that is not RFC3339 is shown as-is. + return until + } + val at = java.time.ZonedDateTime.ofInstant(Instant.ofEpochMilli(millis), ZoneId.systemDefault()) + val clock = SimpleDateFormat("h:mm a", Locale.getDefault()).format(Date(millis)) + return "$clock on ${at.dayOfWeek.name.lowercase().replaceFirstChar { c -> c.uppercase() }}" + } + + private fun appLabel(packageName: String?): String { + if (packageName.isNullOrBlank()) return "This app" + return try { + val pm = context.packageManager + pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString() + } catch (e: Exception) { + packageName + } + } + + private fun dp(value: Int): Int = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), context.resources.displayMetrics).toInt() + + companion object { + private const val TAG = "WebRestrictOverlay" + } +} diff --git a/android/app/src/main/java/au/audasmedia/webrestrict/kids/TamperReporter.kt b/android/app/src/main/java/au/audasmedia/webrestrict/kids/TamperReporter.kt new file mode 100644 index 0000000..f216bb1 --- /dev/null +++ b/android/app/src/main/java/au/audasmedia/webrestrict/kids/TamperReporter.kt @@ -0,0 +1,78 @@ +package au.audasmedia.webrestrict.kids + +import android.content.Context +import android.util.Log +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * FR-17: the real enforcement. A child can disable the Accessibility Service, + * revoke the overlay permission, or force-stop the app; when any of those + * happens the client says so immediately, over the one channel its broker ACL + * allows (its own status topic), so the Control Service can alert Sam. + * + * The report is always written to logcat under [TAG] and appended to + * `files/tamper.log`, so it survives a broker outage. It is published over + * MQTT when the link is up. + */ +object TamperReporter { + const val TAG = "WebRestrictTamper" + + private const val DEDUPE_WINDOW_MS = 3_000L + + /** Reports one tamper event. Duplicate reports within [DEDUPE_WINDOW_MS] are dropped. */ + @Synchronized + fun report(context: Context, kind: String, detail: String) { + val now = System.currentTimeMillis() + val prefs = AppPrefs(context) + if (prefs.lastTamperKind == kind && now - prefs.lastTamperAt < DEDUPE_WINDOW_MS) { + return + } + prefs.lastTamperKind = kind + prefs.lastTamperAt = now + prefs.lastTamperDetail = detail + + val stamp = isoNow(now) + Log.e(TAG, "TAMPER kind=$kind detail=\"$detail\" at=$stamp") + append(context, "$stamp kind=$kind detail=$detail") + MqttController.publishTamper(context, kind, detail) + } + + /** + * Force-stop detection. The service writes a heartbeat every watchdog tick. + * If the app starts again and the heartbeat stopped a while ago, on the same + * boot, the process was killed rather than restarted normally. A reboot is + * excluded by comparing the boot count. + */ + fun detectForceStop(context: Context) { + val prefs = AppPrefs(context) + if (!prefs.armed) return + val last = prefs.heartbeatAt + if (last <= 0L) return + val gap = System.currentTimeMillis() - last + val sameBoot = prefs.bootCount() == prefs.heartbeatBoot + if (sameBoot && gap in MIN_GAP_MS..MAX_GAP_MS) { + report(context, "force_stopped", "no heartbeat for ${gap / 1000}s on the same boot") + } + } + + private fun append(context: Context, line: String) { + try { + val file = File(context.filesDir, "tamper.log") + file.appendText(line + "\n") + } catch (e: Exception) { + Log.w(TAG, "cannot append tamper log: ${e.message}") + } + } + + private fun isoNow(now: Long): String = + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).format(Date(now)) + + // A normal process restart reconnects within a tick or two; a kill leaves a + // much larger gap. Anything past MAX_GAP_MS is treated as the phone being + // off rather than tampering, to avoid a false alarm. + private const val MIN_GAP_MS = 2 * 60 * 1000L + private const val MAX_GAP_MS = 12 * 60 * 60 * 1000L +} diff --git a/android/app/src/main/res/drawable/ic_launcher.xml b/android/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..2d2770c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/android/app/src/main/res/values-night/themes.xml b/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..f064da9 --- /dev/null +++ b/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,9 @@ + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..a545cf9 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + Screen Limits + Screen Limits blocks apps + 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. + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..44b3b5c --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/android/app/src/main/res/xml/accessibility_service_config.xml b/android/app/src/main/res/xml/accessibility_service_config.xml new file mode 100644 index 0000000..d9f333d --- /dev/null +++ b/android/app/src/main/res/xml/accessibility_service_config.xml @@ -0,0 +1,14 @@ + + + diff --git a/android/app/src/test/java/au/audasmedia/webrestrict/kids/BlockEngineTest.kt b/android/app/src/test/java/au/audasmedia/webrestrict/kids/BlockEngineTest.kt new file mode 100644 index 0000000..18cf3ac --- /dev/null +++ b/android/app/src/test/java/au/audasmedia/webrestrict/kids/BlockEngineTest.kt @@ -0,0 +1,61 @@ +package au.audasmedia.webrestrict.kids + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The block rule, tested without an emulator (FR-4, FR-16). */ +class BlockEngineTest { + + private val self = "au.audasmedia.webrestrict.kids" + + @Test + fun `blocks a listed app while locked`() { + val config = DeviceConfig(locked = true, block = listOf("com.android.settings"), reason = "schedule") + val decision = BlockEngine.decide("com.android.settings", config, self) + assertTrue(decision.blocked) + assertEquals("com.android.settings", decision.value) + assertEquals("schedule", decision.reason) + } + + @Test + fun `does not block while unlocked`() { + val config = DeviceConfig(locked = false, block = listOf("com.android.settings")) + assertFalse(BlockEngine.decide("com.android.settings", config, self).blocked) + } + + @Test + fun `allowlist wins over the blocklist`() { + val config = DeviceConfig( + locked = true, + block = listOf("com.spotify.music", "com.youtube.android"), + allow = listOf("com.spotify.music"), + ) + assertFalse(BlockEngine.decide("com.spotify.music", config, self).blocked) + assertTrue(BlockEngine.decide("com.youtube.android", config, self).blocked) + } + + @Test + fun `an unlisted app is not blocked`() { + val config = DeviceConfig(locked = true, block = listOf("com.android.settings")) + assertFalse(BlockEngine.decide("com.example.school", config, self).blocked) + } + + @Test + fun `never blocks itself and ignores a blank package`() { + val config = DeviceConfig(locked = true, block = listOf(self)) + assertFalse(BlockEngine.decide(self, config, self).blocked) + assertFalse(BlockEngine.decide(null, config, self).blocked) + assertFalse(BlockEngine.decide("", config, self).blocked) + } + + @Test + fun `effective block list removes allowed values`() { + val config = DeviceConfig( + block = listOf("a", "b", "c"), + allow = listOf("b"), + ) + assertEquals(listOf("a", "c"), config.effectiveBlock()) + } +} diff --git a/android/app/src/test/java/au/audasmedia/webrestrict/kids/DeviceConfigTest.kt b/android/app/src/test/java/au/audasmedia/webrestrict/kids/DeviceConfigTest.kt new file mode 100644 index 0000000..e8a49fa --- /dev/null +++ b/android/app/src/test/java/au/audasmedia/webrestrict/kids/DeviceConfigTest.kt @@ -0,0 +1,52 @@ +package au.audasmedia.webrestrict.kids + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Parses the exact Phase 3 config payload shape (TRANSPORT_DESIGN.md). */ +class DeviceConfigTest { + + @Test + fun `parses the retained config payload`() { + val json = """ + {"v":1,"device_id":1,"name":"Harry's phone","platform":"android", + "locked":true,"until":"2026-10-13T21:00:00Z", + "allow":["com.spotify.music"],"block":["com.youtube.android"], + "reason":"schedule","grant_until":null, + "generated_at":"2026-10-13T03:30:27Z"} + """.trimIndent() + + val config = DeviceConfig.fromJson(json) + assertEquals(1, config.v) + assertEquals(1L, config.deviceId) + assertTrue(config.locked) + assertEquals("2026-10-13T21:00:00Z", config.until) + assertEquals("schedule", config.reason) + assertEquals(listOf("com.spotify.music"), config.allow) + assertEquals(listOf("com.youtube.android"), config.block) + } + + @Test + fun `missing lists default to empty, never null`() { + val config = DeviceConfig.fromJson("""{"v":1,"locked":false}""") + assertEquals(emptyList(), config.allow) + assertEquals(emptyList(), config.block) + assertFalse(config.locked) + } + + @Test + fun `round trips through json`() { + val original = DeviceConfig(locked = true, block = listOf("com.android.settings"), reason = "manual") + val copy = DeviceConfig.fromJson(original.toJson()) + assertEquals(original, copy) + } + + @Test + fun `the local default blocks an app on a stock emulator`() { + val config = DeviceConfig.default() + assertTrue(config.locked) + assertTrue(config.block.contains("com.android.settings")) + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..5faaff1 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,5 @@ +// Root build file. The Android application module is :app. +plugins { + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..3618c7d --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..a6c9947 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,22 @@ +// Internet Use Control — kids' Android client (AGENTS.md §11 Phase 5). +// +// The toolchain is NOT on .13. `~/android/build.sh internet_use_control` on .27 +// pulls this repo and runs `./gradlew assembleDebug` in this directory. +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "WebRestrictKids" +include(":app") diff --git a/android/tools/emu-proof.sh b/android/tools/emu-proof.sh new file mode 100644 index 0000000..347c68d --- /dev/null +++ b/android/tools/emu-proof.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Emulator proof for the Phase 5.1 Android client. Runs on .27. +# bash emu-proof.sh +set -uo pipefail +exec /dev/null || true +# Toggle so the system binds the service even if it was already set. +adb shell settings put secure accessibility_enabled 0 +adb shell settings delete secure enabled_accessibility_services 2>/dev/null || true +sleep 1 +adb shell settings put secure enabled_accessibility_services $PKG/$PKG.BlockAccessibilityService +adb shell settings put secure accessibility_enabled 1 +sleep 4 +echo "a11y services: $(adb shell settings get secure enabled_accessibility_services)" +echo "a11y enabled: $(adb shell settings get secure accessibility_enabled)" +echo "overlay appop: $(adb shell appops get $PKG SYSTEM_ALERT_WINDOW)" + +echo "== launch app once (seed config, start mqtt) ==" +adb shell am start -n $PKG/.MainActivity +sleep 4 + +echo "== launch a blocked app: Settings ==" +adb shell am force-stop com.android.settings +sleep 1 +adb shell am start -a android.settings.SETTINGS +sleep 5 + +echo "== window state: overlay ==" +adb shell dumpsys window windows | grep -i -A30 "u0 $PKG}:" | grep -E "Window #|ty=|isOnScreen|isVisible|mPolicyVisibility|shown=|mForceHide" | head -20 +echo "current focus:" +adb shell dumpsys window | grep -E "mCurrentFocus" | head -2 + +echo "== screenshot: block overlay ==" +adb exec-out screencap -p > "$OUT/block-overlay.png" +echo "wrote $OUT/block-overlay.png ($(stat -c%s "$OUT/block-overlay.png") bytes)" + +echo "== logcat: service + overlay ==" +adb logcat -d -s WebRestrictService WebRestrictOverlay WebRestrictConfig WebRestrictMqtt 2>/dev/null | tail -30 + +echo "== TAMPER: disable the Accessibility Service ==" +adb shell settings put secure accessibility_enabled 0 +adb shell settings delete secure enabled_accessibility_services 2>/dev/null || true +sleep 5 +adb logcat -d -s WebRestrictTamper 2>/dev/null | tail -20 | tee "$OUT/tamper-logcat.txt" + +echo "== tamper log file (persisted on device) ==" +adb shell run-as $PKG cat files/tamper.log 2>/dev/null | tail -20 | tee "$OUT/tamper-file.txt" || true + +echo "== screenshot: status screen after tamper ==" +adb shell am start -n $PKG/.MainActivity +sleep 3 +adb exec-out screencap -p > "$OUT/after-tamper.png" +echo "wrote $OUT/after-tamper.png ($(stat -c%s "$OUT/after-tamper.png") bytes)" + +echo "== done ==" diff --git a/cmd/control/main.go b/cmd/control/main.go index 088a055..099cacf 100644 --- a/cmd/control/main.go +++ b/cmd/control/main.go @@ -18,6 +18,7 @@ import ( "os/signal" "strconv" "strings" + "sync/atomic" "syscall" "time" @@ -152,6 +153,12 @@ func run() error { link *mqtt.Link pub web.Publisher ) + // alerts holds the Phase 9 engine once it exists. The broker link is built + // before the web server (and therefore before the engine), so a status + // payload that carries a tamper report (FR-17) reaches the engine through + // this pointer. Absent a configured engine, the tamper is still logged by + // the link and nothing is delivered. + var alerts atomic.Pointer[alert.Engine] if cfg.MQTTEnabled { link, err = mqtt.New(mqtt.Options{ Broker: cfg.MQTTBroker, @@ -166,6 +173,14 @@ func run() error { Source: st, Reports: st, OnConnected: func() { republish(ctx, log, link, st) }, + OnTamper: func(tctx context.Context, dev store.Device, t mqtt.Tamper) { + engine := alerts.Load() + if engine == nil { + log.Warn("tamper received but alerts are disabled", "device", dev.Name, "kind", t.Kind) + return + } + engine.Raise(tctx, dev, store.AlertTamper, alert.TamperMessage(dev, t.Kind, t.Detail)) + }, }) if err != nil { return err @@ -193,6 +208,7 @@ func run() error { if cfg.AlertsEnabled { engine := alert.New(st, srv.Apprise(), log, cfg.AlertOfflineAfter, cfg.ScheduleLocation) engine.Interval = cfg.AlertInterval + alerts.Store(engine) go engine.Run(ctx) if !cfg.AppriseConfigured() { log.Warn("alerts are enabled but Apprise is not configured; alerts will be recorded as undelivered") diff --git a/internal/alert/alert.go b/internal/alert/alert.go index 824a340..ff427f0 100644 --- a/internal/alert/alert.go +++ b/internal/alert/alert.go @@ -153,6 +153,38 @@ func (e *Engine) Evaluate(ctx context.Context) { } } +// Raise records and delivers one alert that the engine did not observe itself, +// such as a device tamper report arriving over MQTT (FR-17). It is a thin +// wrapper over the same fire path, so delivery, recording and the audit row are +// identical to an observed alert. +func (e *Engine) Raise(ctx context.Context, dev store.Device, kind, message string) { + e.fire(ctx, dev, kind, message) +} + +// TamperMessage is the one sentence Sam receives when a kids' client reports +// that its enforcement was bypassed. +func TamperMessage(dev store.Device, kind, detail string) string { + what := tamperDescription(kind) + if detail != "" { + return fmt.Sprintf("%s reported tampering: %s (%s).", dev.Name, what, detail) + } + return fmt.Sprintf("%s reported tampering: %s.", dev.Name, what) +} + +// tamperDescription turns the client's short kind token into a readable phrase. +func tamperDescription(kind string) string { + switch kind { + case "accessibility_disabled": + return "the Accessibility Service was disabled" + case "overlay_revoked": + return "the overlay permission was revoked" + case "force_stopped": + return "the app was force stopped" + default: + return "the client enforcement was bypassed" + } +} + // fire records an alert and delivers it. A delivery failure is recorded on the // row and logged; it is never retried here, so a broken Apprise cannot spin. func (e *Engine) fire(ctx context.Context, dev store.Device, kind, message string) { @@ -239,6 +271,8 @@ func alertTitle(kind string) string { return "limit hit" case store.AlertOffline: return "device offline" + case store.AlertTamper: + return "tamper alert" default: return "notification" } diff --git a/internal/alert/alert_test.go b/internal/alert/alert_test.go index 8372618..633f6fa 100644 --- a/internal/alert/alert_test.go +++ b/internal/alert/alert_test.go @@ -3,6 +3,7 @@ package alert import ( "context" "log/slog" + "strings" "testing" "time" @@ -176,3 +177,43 @@ func TestUndeliveredWhenNotConfigured(t *testing.T) { t.Error("alert marked delivered with an unconfigured notifier") } } + +func TestRaiseTamperDeliversOnce(t *testing.T) { + now := time.Date(2026, 10, 13, 20, 30, 0, 0, time.UTC) + fs := newFakeStore(store.Device{ID: 1, Name: "Harry's phone", Child: "Harry"}) + fn := &fakeNotifier{configured: true} + e := newEngine(fs, fn, now) + + dev := store.Device{ID: 1, Name: "Harry's phone"} + e.Raise(context.Background(), dev, store.AlertTamper, + TamperMessage(dev, "accessibility_disabled", "service turned off")) + + if len(fs.alerts) != 1 { + t.Fatalf("alerts = %d, want 1", len(fs.alerts)) + } + a := fs.alerts[0] + if a.Kind != store.AlertTamper { + t.Errorf("kind = %q, want %q", a.Kind, store.AlertTamper) + } + if !a.Delivered { + t.Error("tamper alert was not delivered") + } + if len(fn.sent) != 1 || !strings.Contains(fn.sent[0], "Harry's phone") { + t.Errorf("delivered bodies = %v", fn.sent) + } +} + +func TestTamperMessagePhrases(t *testing.T) { + dev := store.Device{ID: 1, Name: "Finn's computer"} + for kind, want := range map[string]string{ + "accessibility_disabled": "the Accessibility Service was disabled", + "overlay_revoked": "the overlay permission was revoked", + "force_stopped": "the app was force stopped", + "something_new": "the client enforcement was bypassed", + } { + got := TamperMessage(dev, kind, "") + if !strings.Contains(got, want) { + t.Errorf("TamperMessage(%q) = %q, want it to contain %q", kind, got, want) + } + } +} diff --git a/internal/mqtt/link.go b/internal/mqtt/link.go index c13f50d..54c237b 100644 --- a/internal/mqtt/link.go +++ b/internal/mqtt/link.go @@ -75,6 +75,10 @@ type Options struct { Source ConfigSource // Reports is required to store device reports. Reports ReportSink + // OnTamper, when set, is called for every status payload that carries a + // tamper report (FR-17). It runs on the broker's callback goroutine, so it + // must return quickly; the service uses it to raise one Apprise alert. + OnTamper func(ctx context.Context, dev store.Device, tamper Tamper) // OnConnected runs after every successful (re)connect. The service uses it // to republish every device's retained config. OnConnected func() @@ -280,6 +284,15 @@ func (l *Link) handleStatusMessage(_ paho.Client, msg paho.Message) { l.log.Info("presence changed", "device", dev.Name, "topic", msg.Topic(), "online", online, "retained", msg.Retained()) } + + if status.HasTamper() { + t := *status.Tamper + l.log.Warn("device tamper reported", "device", dev.Name, "topic", msg.Topic(), + "kind", t.Kind, "detail", t.Detail) + if l.opts.OnTamper != nil { + l.opts.OnTamper(ctx, *dev, t) + } + } } // handleTelemetryMessage stores a device usage report. diff --git a/internal/mqtt/link_test.go b/internal/mqtt/link_test.go new file mode 100644 index 0000000..8a91470 --- /dev/null +++ b/internal/mqtt/link_test.go @@ -0,0 +1,110 @@ +package mqtt + +import ( + "context" + "log/slog" + "testing" + "time" + + "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store" +) + +// fakes for the link's collaborators. + +type fakeDevices struct{ dev store.Device } + +func (f fakeDevices) DeviceByConfigTopic(context.Context, string) (*store.Device, error) { + d := f.dev + return &d, nil +} + +type fakeReports struct{ presence bool } + +func (f *fakeReports) SetPresence(_ context.Context, _ int64, online bool, _ time.Time, _ string) (bool, error) { + f.presence = online + return true, nil +} + +func (f *fakeReports) InsertTelemetry(context.Context, int64, string, []store.TelemetryEntry) (int, error) { + return 0, nil +} + +type fakeSource struct{} + +func (fakeSource) ListDevices(context.Context) ([]store.Device, error) { return nil, nil } + +func (fakeSource) EffectiveState(context.Context, store.Device, time.Time) (store.EffectiveState, error) { + return store.EffectiveState{}, nil +} + +// fakeMessage satisfies paho.Message for a received report. +type fakeMessage struct { + topic string + payload []byte +} + +func (m fakeMessage) Duplicate() bool { return false } +func (m fakeMessage) Qos() byte { return 1 } +func (m fakeMessage) Retained() bool { return true } +func (m fakeMessage) Topic() string { return m.topic } +func (m fakeMessage) MessageID() uint16 { return 0 } +func (m fakeMessage) Payload() []byte { return m.payload } +func (m fakeMessage) Ack() {} + +func newTestLink(t *testing.T, dev store.Device, reports *fakeReports, onTamper func(context.Context, store.Device, Tamper)) *Link { + t.Helper() + l, err := New(Options{ + Broker: "tcp://broker.invalid:1883", + Log: slog.New(slog.DiscardHandler), + Devices: fakeDevices{dev: dev}, + Source: fakeSource{}, + Reports: reports, + OnTamper: onTamper, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + // The paho client itself is never used by handleStatusMessage. + l.client = nil + return l +} + +func TestStatusTamperReachesTheSink(t *testing.T) { + dev := store.Device{ID: 1, Name: "Harry's phone", Topic: "restrict/harry_phone_1/config"} + reports := &fakeReports{} + + var got Tamper + var calls int + l := newTestLink(t, dev, reports, func(_ context.Context, d store.Device, tamper Tamper) { + calls++ + got = tamper + if d.ID != dev.ID { + t.Errorf("device id = %d, want %d", d.ID, dev.ID) + } + }) + + payload := `{"online":true,"tamper":{"kind":"accessibility_disabled","detail":"the service was destroyed"}}` + l.handleStatusMessage(nil, fakeMessage{topic: "restrict/harry_phone_1/status", payload: []byte(payload)}) + + if calls != 1 { + t.Fatalf("OnTamper calls = %d, want 1", calls) + } + if got.Kind != "accessibility_disabled" { + t.Errorf("kind = %q", got.Kind) + } + if !reports.presence { + t.Error("presence was not recorded alongside the tamper") + } +} + +func TestPlainStatusDoesNotTamper(t *testing.T) { + dev := store.Device{ID: 2, Name: "Finn's phone", Topic: "restrict/finn_phone_2/config"} + reports := &fakeReports{} + l := newTestLink(t, dev, reports, func(context.Context, store.Device, Tamper) { + t.Error("OnTamper called for a plain status") + }) + l.handleStatusMessage(nil, fakeMessage{topic: "restrict/finn_phone_2/status", payload: []byte(`{"online":true}`)}) + if !reports.presence { + t.Error("presence was not recorded") + } +} diff --git a/internal/mqtt/payload.go b/internal/mqtt/payload.go index 11deb5e..e2d3df2 100644 --- a/internal/mqtt/payload.go +++ b/internal/mqtt/payload.go @@ -123,11 +123,27 @@ func (c Config) Marshal() ([]byte, error) { // registers the will {"online": false} on the same topic, so the broker // publishes "offline" the moment the connection drops without a clean // goodbye. That is the whole of presence detection (FR-6). +// +// A device may also attach a tamper report (FR-17): the accessibility service +// was disabled, the overlay permission was revoked, or the app was force +// stopped. It arrives on the same status topic because that is the only topic +// a device's broker ACL lets it write. Unknown fields stay ignored, so the +// shape is additive. type Status struct { - Online *bool `json:"online"` - App string `json:"app"` - Locked *bool `json:"locked"` - At string `json:"at"` + Online *bool `json:"online"` + App string `json:"app"` + Locked *bool `json:"locked"` + At string `json:"at"` + Tamper *Tamper `json:"tamper,omitempty"` +} + +// Tamper is a device-reported bypass of the kids' client (AGENTS.md FR-17). +// Kind is a short machine token such as "accessibility_disabled", +// "overlay_revoked" or "force_stopped"; Detail is free text for the alert. +type Tamper struct { + Kind string `json:"kind"` + Detail string `json:"detail,omitempty"` + At string `json:"at,omitempty"` } // ParseStatus reads a status payload. A report with no "online" field means the @@ -146,6 +162,9 @@ func ParseStatus(payload []byte) (Status, error) { // IsOnline reports the presence the payload asserts. func (s Status) IsOnline() bool { return s.Online == nil || *s.Online } +// HasTamper reports whether the payload carries a tamper report with a kind. +func (s Status) HasTamper() bool { return s.Tamper != nil && s.Tamper.Kind != "" } + // At parses the report timestamp, falling back to the current time. func (s Status) Timestamp(now time.Time) time.Time { if s.At == "" { diff --git a/internal/mqtt/payload_test.go b/internal/mqtt/payload_test.go index fa27245..271cb76 100644 --- a/internal/mqtt/payload_test.go +++ b/internal/mqtt/payload_test.go @@ -279,3 +279,30 @@ func TestBuildConfigAllowlistIsolation(t *testing.T) { } } } + +func TestParseStatusTamper(t *testing.T) { + payload := []byte(`{"online":true,"app":"com.android.settings",` + + `"tamper":{"kind":"accessibility_disabled","detail":"service turned off",` + + `"at":"2026-10-13T08:00:00Z"},"at":"2026-10-13T08:00:01Z"}`) + st, err := ParseStatus(payload) + if err != nil { + t.Fatalf("ParseStatus: %v", err) + } + if !st.HasTamper() { + t.Fatal("HasTamper = false, want true") + } + if st.Tamper.Kind != "accessibility_disabled" { + t.Errorf("kind = %q", st.Tamper.Kind) + } + if st.Tamper.Detail != "service turned off" { + t.Errorf("detail = %q", st.Tamper.Detail) + } + + plain, err := ParseStatus([]byte(`{"online":false}`)) + if err != nil { + t.Fatalf("ParseStatus plain: %v", err) + } + if plain.HasTamper() { + t.Error("plain status reported a tamper") + } +} diff --git a/internal/store/alerts.go b/internal/store/alerts.go index 0fa3823..b2df156 100644 --- a/internal/store/alerts.go +++ b/internal/store/alerts.go @@ -18,11 +18,13 @@ import ( ) // Alert kinds. 'limit' is a device that became blocked, 'offline' is a device -// unreachable for too long, 'notify' is an accepted notification delivered on -// demand (for example the future phone tamper report, FR-17). +// unreachable for too long, 'tamper' is a kids' client that reported its own +// enforcement was bypassed (FR-17), 'notify' is an accepted notification +// delivered on demand. const ( AlertLimit = "limit" AlertOffline = "offline" + AlertTamper = "tamper" AlertNotify = "notify" ) @@ -50,7 +52,7 @@ type AlertState struct { // transaction (FR-15). deviceID may be nil for a notification with no device. func (s *Store) CreateAlert(ctx context.Context, deviceID *int64, kind, message string) (*Alert, error) { switch kind { - case AlertLimit, AlertOffline, AlertNotify: + case AlertLimit, AlertOffline, AlertTamper, AlertNotify: default: return nil, fmt.Errorf("create alert: unknown kind %q", kind) }