From e018c3bfa8fa084bba0f12b1c291984d5259254f Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Tue, 13 Oct 2026 20:17:22 +1100 Subject: [PATCH] Phase 5.1: kids' Android client (Accessibility block overlay + tamper alert) Add android/, a normal Gradle/Kotlin project with its own wrapper. It blocks by interruption: an Accessibility Service watches the foreground package and a full-screen overlay covers any listed app while the device is locked (FR-16). No Device Owner, no factory reset, no managed profile. The client subscribes to the retained restrict//config topic on the 8883 TLS plane, keeps the last config on disk so a broker-less device keeps its rules (FR-14), and reports tampering on its own status topic (FR-17): a disabled service, a revoked overlay permission, or a force-stop seen on the next start. The Control Service now parses the tamper field and raises an Apprise alert, so it reaches Sam. 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. Co-Authored-By: Paperclip --- README.md | 27 ++ android/.gitignore | 15 + android/README.md | 63 ++++ android/app/build.gradle.kts | 71 +++++ android/app/proguard-rules.pro | 3 + android/app/src/main/AndroidManifest.xml | 68 +++++ .../audasmedia/webrestrict/kids/AppPrefs.kt | 154 ++++++++++ .../kids/BlockAccessibilityService.kt | 141 +++++++++ .../webrestrict/kids/BlockEngine.kt | 47 +++ .../webrestrict/kids/BootReceiver.kt | 27 ++ .../webrestrict/kids/DeviceConfig.kt | 61 ++++ .../au/audasmedia/webrestrict/kids/KidsApp.kt | 26 ++ .../webrestrict/kids/MainActivity.kt | 189 ++++++++++++ .../webrestrict/kids/MqttController.kt | 276 ++++++++++++++++++ .../webrestrict/kids/OverlayController.kt | 179 ++++++++++++ .../webrestrict/kids/TamperReporter.kt | 78 +++++ .../app/src/main/res/drawable/ic_launcher.xml | 14 + .../app/src/main/res/values-night/themes.xml | 9 + android/app/src/main/res/values/strings.xml | 8 + android/app/src/main/res/values/themes.xml | 10 + .../res/xml/accessibility_service_config.xml | 14 + .../webrestrict/kids/BlockEngineTest.kt | 61 ++++ .../webrestrict/kids/DeviceConfigTest.kt | 52 ++++ android/build.gradle.kts | 5 + android/gradle.properties | 5 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + android/gradlew | 252 ++++++++++++++++ android/gradlew.bat | 94 ++++++ android/settings.gradle.kts | 22 ++ android/tools/emu-proof.sh | 76 +++++ cmd/control/main.go | 16 + internal/alert/alert.go | 34 +++ internal/alert/alert_test.go | 41 +++ internal/mqtt/link.go | 13 + internal/mqtt/link_test.go | 110 +++++++ internal/mqtt/payload.go | 27 +- internal/mqtt/payload_test.go | 27 ++ internal/store/alerts.go | 8 +- 39 files changed, 2323 insertions(+), 7 deletions(-) create mode 100644 android/.gitignore create mode 100644 android/README.md create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/au/audasmedia/webrestrict/kids/AppPrefs.kt create mode 100644 android/app/src/main/java/au/audasmedia/webrestrict/kids/BlockAccessibilityService.kt create mode 100644 android/app/src/main/java/au/audasmedia/webrestrict/kids/BlockEngine.kt create mode 100644 android/app/src/main/java/au/audasmedia/webrestrict/kids/BootReceiver.kt create mode 100644 android/app/src/main/java/au/audasmedia/webrestrict/kids/DeviceConfig.kt create mode 100644 android/app/src/main/java/au/audasmedia/webrestrict/kids/KidsApp.kt create mode 100644 android/app/src/main/java/au/audasmedia/webrestrict/kids/MainActivity.kt create mode 100644 android/app/src/main/java/au/audasmedia/webrestrict/kids/MqttController.kt create mode 100644 android/app/src/main/java/au/audasmedia/webrestrict/kids/OverlayController.kt create mode 100644 android/app/src/main/java/au/audasmedia/webrestrict/kids/TamperReporter.kt create mode 100644 android/app/src/main/res/drawable/ic_launcher.xml create mode 100644 android/app/src/main/res/values-night/themes.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/app/src/main/res/xml/accessibility_service_config.xml create mode 100644 android/app/src/test/java/au/audasmedia/webrestrict/kids/BlockEngineTest.kt create mode 100644 android/app/src/test/java/au/audasmedia/webrestrict/kids/DeviceConfigTest.kt create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle.kts create mode 100644 android/tools/emu-proof.sh create mode 100644 internal/mqtt/link_test.go 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 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ zQccdaaVK$5bz;*rnyK{_SQYM>;aES6Qs^lj9lEs6_J+%nIiuQC*fN;z8md>r_~Mfl zU%p5Dt_YT>gQqfr@`cR!$NWr~+`CZb%dn;WtzrAOI>P_JtsB76PYe*<%H(y>qx-`Kq!X_; z<{RpAqYhE=L1r*M)gNF3B8r(<%8mo*SR2hu zccLRZwGARt)Hlo1euqTyM>^!HK*!Q2P;4UYrysje@;(<|$&%vQekbn|0Ruu_Io(w4#%p6ld2Yp7tlA`Y$cciThP zKzNGIMPXX%&Ud0uQh!uQZz|FB`4KGD?3!ND?wQt6!n*f4EmCoJUh&b?;B{|lxs#F- z31~HQ`SF4x$&v00@(P+j1pAaj5!s`)b2RDBp*PB=2IB>oBF!*6vwr7Dp%zpAx*dPr zb@Zjq^XjN?O4QcZ*O+8>)|HlrR>oD*?WQl5ri3R#2?*W6iJ>>kH%KnnME&TT@ZzrHS$Q%LC?n|e>V+D+8D zYc4)QddFz7I8#}y#Wj6>4P%34dZH~OUDb?uP%-E zwjXM(?Sg~1!|wI(RVuxbu)-rH+O=igSho_pDCw(c6b=P zKk4ATlB?bj9+HHlh<_!&z0rx13K3ZrAR8W)!@Y}o`?a*JJsD+twZIv`W)@Y?Amu_u zz``@-e2X}27$i(2=9rvIu5uTUOVhzwu%mNazS|lZb&PT;XE2|B&W1>=B58#*!~D&) zfVmJGg8UdP*fx(>Cj^?yS^zH#o-$Q-*$SnK(ZVFkw+er=>N^7!)FtP3y~Xxnu^nzY zikgB>Nj0%;WOltWIob|}%lo?_C7<``a5hEkx&1ku$|)i>Rh6@3h*`slY=9U}(Ql_< zaNG*J8vb&@zpdhAvv`?{=zDedJ23TD&Zg__snRAH4eh~^oawdYi6A3w8<Ozh@Kw)#bdktM^GVb zrG08?0bG?|NG+w^&JvD*7LAbjED{_Zkc`3H!My>0u5Q}m!+6VokMLXxl`Mkd=g&Xx z-a>m*#G3SLlhbKB!)tnzfWOBV;u;ftU}S!NdD5+YtOjLg?X}dl>7m^gOpihrf1;PY zvll&>dIuUGs{Qnd- zwIR3oIrct8Va^Tm0t#(bJD7c$Z7DO9*7NnRZorrSm`b`cxz>OIC;jSE3DO8`hX955ui`s%||YQtt2 z5DNA&pG-V+4oI2s*x^>-$6J?p=I>C|9wZF8z;VjR??Icg?1w2v5Me+FgAeGGa8(3S z4vg*$>zC-WIVZtJ7}o9{D-7d>zCe|z#<9>CFve-OPAYsneTb^JH!Enaza#j}^mXy1 z+ULn^10+rWLF6j2>Ya@@Kq?26>AqK{A_| zQKb*~F1>sE*=d?A?W7N2j?L09_7n+HGi{VY;MoTGr_)G9)ot$p!-UY5zZ2Xtbm=t z@dpPSGwgH=QtIcEulQNI>S-#ifbnO5EWkI;$A|pxJd885oM+ zGZ0_0gDvG8q2xebj+fbCHYfAXuZStH2j~|d^sBAzo46(K8n59+T6rzBwK)^rfPT+B zyIFw)9YC-V^rhtK`!3jrhmW-sTmM+tPH+;nwjL#-SjQPUZ53L@A>y*rt(#M(qsiB2 zx6B)dI}6Wlsw%bJ8h|(lhkJVogQZA&n{?Vgs6gNSXzuZpEyu*xySy8ro07QZ7Vk1!3tJphN_5V7qOiyK8p z#@jcDD8nmtYi1^l8ml;AF<#IPK?!pqf9D4moYk>d99Im}Jtwj6c#+A;f)CQ*f-hZ< z=p_T86jog%!p)D&5g9taSwYi&eP z#JuEK%+NULWus;0w32-SYFku#i}d~+{Pkho&^{;RxzP&0!RCm3-9K6`>KZpnzS6?L z^H^V*s!8<>x8bomvD%rh>Zp3>Db%kyin;qtl+jAv8Oo~1g~mqGAC&Qi_wy|xEt2iz zWAJEfTV%cl2Cs<1L&DLRVVH05EDq`pH7Oh7sR`NNkL%wi}8n>IXcO40hp+J+sC!W?!krJf!GJNE8uj zg-y~Ns-<~D?yqbzVRB}G>0A^f0!^N7l=$m0OdZuqAOQqLc zX?AEGr1Ht+inZ-Qiwnl@Z0qukd__a!C*CKuGdy5#nD7VUBM^6OCpxCa2A(X;e0&V4 zM&WR8+wErQ7UIc6LY~Q9x%Sn*Tn>>P`^t&idaOEnOd(Ufw#>NoR^1QdhJ8s`h^|R_ zXX`c5*O~Xdvh%q;7L!_!ohf$NfEBmCde|#uVZvEo>OfEq%+Ns7&_f$OR9xsihRpBb z+cjk8LyDm@U{YN>+r46?nn{7Gh(;WhFw6GAxtcKD+YWV?uge>;+q#Xx4!GpRkVZYu zzsF}1)7$?%s9g9CH=Zs+B%M_)+~*j3L0&Q9u7!|+T`^O{xE6qvAP?XWv9_MrZKdo& z%IyU)$Q95AB4!#hT!_dA>4e@zjOBD*Y=XjtMm)V|+IXzjuM;(l+8aA5#Kaz_$rR6! zj>#&^DidYD$nUY(D$mH`9eb|dtV0b{S>H6FBfq>t5`;OxA4Nn{J(+XihF(stSche7$es&~N$epi&PDM_N`As;*9D^L==2Q7Z2zD+CiU(|+-kL*VG+&9!Yb3LgPy?A zm7Z&^qRG_JIxK7-FBzZI3Q<;{`DIxtc48k> zc|0dmX;Z=W$+)qE)~`yn6MdoJ4co;%!`ddy+FV538Y)j(vg}5*k(WK)KWZ3WaOG!8 z!syGn=s{H$odtpqFrT#JGM*utN7B((abXnpDM6w56nhw}OY}0TiTG1#f*VFZr+^-g zbP10`$LPq_;PvrA1XXlyx2uM^mrjTzX}w{yuLo-cOClE8MMk47T25G8M!9Z5ypOSV zAJUBGEg5L2fY)ZGJb^E34R2zJ?}Vf>{~gB!8=5Z) z9y$>5c)=;o0HeHHSuE4U)#vG&KF|I%-cF6f$~pdYJWk_dD}iOA>iA$O$+4%@>JU08 zS`ep)$XLPJ+n0_i@PkF#ri6T8?ZeAot$6JIYHm&P6EB=BiaNY|aA$W0I+nz*zkz_z zkEru!tj!QUffq%)8y0y`T&`fuus-1p>=^hnBiBqD^hXrPs`PY9tU3m0np~rISY09> z`P3s=-kt_cYcxWd{de@}TwSqg*xVhp;E9zCsnXo6z z?f&Sv^U7n4`xr=mXle94HzOdN!2kB~4=%)u&N!+2;z6UYKUDqi-s6AZ!haB;@&B`? z_TRX0%@suz^TRdCb?!vNJYPY8L_}&07uySH9%W^Tc&1pia6y1q#?*Drf}GjGbPjBS zbOPcUY#*$3sL2x4v_i*Y=N7E$mR}J%|GUI(>WEr+28+V z%v5{#e!UF*6~G&%;l*q*$V?&r$Pp^sE^i-0$+RH3ERUUdQ0>rAq2(2QAbG}$y{de( z>{qD~GGuOk559Y@%$?N^1ApVL_a704>8OD%8Y%8B;FCt%AoPu8*D1 zLB5X>b}Syz81pn;xnB}%0FnwazlWfUV)Z-~rZg6~b z6!9J$EcE&sEbzcy?CI~=boWA&eeIa%z(7SE^qgVLz??1Vbc1*aRvc%Mri)AJaAG!p z$X!_9Ds;Zz)f+;%s&dRcJt2==P{^j3bf0M=nJd&xwUGlUFn?H=2W(*2I2Gdu zv!gYCwM10aeus)`RIZSrCK=&oKaO_Ry~D1B5!y0R=%!i2*KfXGYX&gNv_u+n9wiR5 z*e$Zjju&ODRW3phN925%S(jL+bCHv6rZtc?!*`1TyYXT6%Ju=|X;6D@lq$8T zW{Y|e39ioPez(pBH%k)HzFITXHvnD6hw^lIoUMA;qAJ^CU?top1fo@s7xT13Fvn1H z6JWa-6+FJF#x>~+A;D~;VDs26>^oH0EI`IYT2iagy23?nyJ==i{g4%HrAf1-*v zK1)~@&(KkwR7TL}L(A@C_S0G;-GMDy=MJn2$FP5s<%wC)4jC5PXoxrQBFZ_k0P{{s@sz+gX`-!=T8rcB(=7vW}^K6oLWMmp(rwDh}b zwaGGd>yEy6fHv%jM$yJXo5oMAQ>c9j`**}F?MCry;T@47@r?&sKHgVe$MCqk#Z_3S z1GZI~nOEN*P~+UaFGnj{{Jo@16`(qVNtbU>O0Hf57-P>x8Jikp=`s8xWs^dAJ9lCQ z)GFm+=OV%AMVqVATtN@|vp61VVAHRn87}%PC^RAzJ%JngmZTasWBAWsoAqBU+8L8u z4A&Pe?fmTm0?mK-BL9t+{y7o(7jm+RpOhL9KnY#E&qu^}B6=K_dB}*VlSEiC9fn)+V=J;OnN)Ta5v66ic1rG+dGAJ1 z1%Zb_+!$=tQ~lxQrzv3x#CPb?CekEkA}0MYSgx$Jdd}q8+R=ma$|&1a#)TQ=l$1tQ z=tL9&_^vJ)Pk}EDO-va`UCT1m#Uty1{v^A3P~83_#v^ozH}6*9mIjIr;t3Uv%@VeW zGL6(CwCUp)Jq%G0bIG%?{_*Y#5IHf*5M@wPo6A{$Um++Co$wLC=J1aoG93&T7Ho}P z=mGEPP7GbvoG!uD$k(H3A$Z))+i{Hy?QHdk>3xSBXR0j!11O^mEe9RHmw!pvzv?Ua~2_l2Yh~_!s1qS`|0~0)YsbHSz8!mG)WiJE| z2f($6TQtt6L_f~ApQYQKSb=`053LgrQq7G@98#igV>y#i==-nEjQ!XNu9 z~;mE+gtj4IDDNQJ~JVk5Ux6&LCSFL!y=>79kE9=V}J7tD==Ga+IW zX)r7>VZ9dY=V&}DR))xUoV!u(Z|%3ciQi_2jl}3=$Agc(`RPb z8kEBpvY>1FGQ9W$n>Cq=DIpski};nE)`p3IUw1Oz0|wxll^)4dq3;CCY@RyJgFgc# zKouFh!`?Xuo{IMz^xi-h=StCis_M7yq$u) z?XHvw*HP0VgR+KR6wI)jEMX|ssqYvSf*_3W8zVTQzD?3>H!#>InzpSO)@SC8q*ii- z%%h}_#0{4JG;Jm`4zg};BPTGkYamx$Xo#O~lBirRY)q=5M45n{GCfV7h9qwyu1NxOMoP4)jjZMxmT|IQQh0U7C$EbnMN<3)Kk?fFHYq$d|ICu>KbY_hO zTZM+uKHe(cIZfEqyzyYSUBZa8;Fcut-GN!HSA9ius`ltNebF46ZX_BbZNU}}ZOm{M2&nANL9@0qvih15(|`S~z}m&h!u4x~(%MAO$jHRWNfuxWF#B)E&g3ghSQ9|> z(MFaLQj)NE0lowyjvg8z0#m6FIuKE9lDO~Glg}nSb7`~^&#(Lw{}GVOS>U)m8bF}x zVjbXljBm34Cs-yM6TVusr+3kYFjr28STT3g056y3cH5Tmge~ASxBj z%|yb>$eF;WgrcOZf569sDZOVwoo%8>XO>XQOX1OyN9I-SQgrm;U;+#3OI(zrWyow3 zk==|{lt2xrQ%FIXOTejR>;wv(Pb8u8}BUpx?yd(Abh6? zsoO3VYWkeLnF43&@*#MQ9-i-d0t*xN-UEyNKeyNMHw|A(k(_6QKO=nKMCxD(W(Yop zsRQ)QeL4X3Lxp^L%wzi2-WVSsf61dqliPUM7srDB?Wm6Lzn0&{*}|IsKQW;02(Y&| zaTKv|`U(pSzuvR6Rduu$wzK_W-Y-7>7s?G$)U}&uK;<>vU}^^ns@Z!p+9?St1s)dG zK%y6xkPyyS1$~&6v{kl?Md6gwM|>mt6Upm>oa8RLD^8T{0?HC!Z>;(Bob7el(DV6x zi`I)$&E&ngwFS@bi4^xFLAn`=fzTC;aimE^!cMI2n@Vo%Ae-ne`RF((&5y6xsjjAZ zVguVoQ?Z9uk$2ON;ersE%PU*xGO@T*;j1BO5#TuZKEf(mB7|g7pcEA=nYJ{s3vlbg zd4-DUlD{*6o%Gc^N!Nptgay>j6E5;3psI+C3Q!1ZIbeCubW%w4pq9)MSDyB{HLm|k zxv-{$$A*pS@csolri$Ge<4VZ}e~78JOL-EVyrbxKra^d{?|NnPp86!q>t<&IP07?Z z^>~IK^k#OEKgRH+LjllZXk7iA>2cfH6+(e&9ku5poo~6y{GC5>(bRK7hwjiurqAiZ zg*DmtgY}v83IjE&AbiWgMyFbaRUPZ{lYiz$U^&Zt2YjG<%m((&_JUbZcfJ22(>bi5 z!J?<7AySj0JZ&<-qXX;mcV!f~>G=sB0KnjWca4}vrtunD^1TrpfeS^4dvFr!65knK zZh`d;*VOkPs4*-9kL>$GP0`(M!j~B;#x?Ba~&s6CopvO86oM?-? zOw#dIRc;6A6T?B`Qp%^<U5 z19x(ywSH$_N+Io!6;e?`tWaM$`=Db!gzx|lQ${DG!zb1Zl&|{kX0y6xvO1o z220r<-oaS^^R2pEyY;=Qllqpmue|5yI~D|iI!IGt@iod{Opz@*ml^w2bNs)p`M(Io z|E;;m*Xpjd9l)4G#KaWfV(t8YUn@A;nK^#xgv=LtnArX|vWQVuw3}B${h+frU2>9^ z!l6)!Uo4`5k`<<;E(ido7M6lKTgWezNLq>U*=uz&s=cc$1%>VrAeOoUtA|T6gO4>UNqsdK=NF*8|~*sl&wI=x9-EGiq*aqV!(VVXA57 zw9*o6Ir8Lj1npUXvlevtn(_+^X5rzdR>#(}4YcB9O50q97%rW2me5_L=%ffYPUSRc z!vv?Kv>dH994Qi>U(a<0KF6NH5b16enCp+mw^Hb3Xs1^tThFpz!3QuN#}KBbww`(h z7GO)1olDqy6?T$()R7y%NYx*B0k_2IBiZ14&8|JPFxeMF{vW>HF-Vi3+ZOI=+qP}n zw(+!WcTd~4ZJX1!ZM&y!+uyt=&i!+~d(V%GjH;-NsEEv6nS1TERt|RHh!0>W4+4pp z1-*EzAM~i`+1f(VEHI8So`S`akPfPTfq*`l{Fz`hS%k#JS0cjT2mS0#QLGf=J?1`he3W*;m4)ce8*WFq1sdP=~$5RlH1EdWm|~dCvKOi4*I_96{^95p#B<(n!d?B z=o`0{t+&OMwKcxiBECznJcfH!fL(z3OvmxP#oWd48|mMjpE||zdiTBdWelj8&Qosv zZFp@&UgXuvJw5y=q6*28AtxZzo-UUpkRW%ne+Ylf!V-0+uQXBW=5S1o#6LXNtY5!I z%Rkz#(S8Pjz*P7bqB6L|M#Er{|QLae-Y{KA>`^} z@lPjeX>90X|34S-7}ZVXe{wEei1<{*e8T-Nbj8JmD4iwcE+Hg_zhkPVm#=@b$;)h6 z<<6y`nPa`f3I6`!28d@kdM{uJOgM%`EvlQ5B2bL)Sl=|y@YB3KeOzz=9cUW3clPAU z^sYc}xf9{4Oj?L5MOlYxR{+>w=vJjvbyO5}ptT(o6dR|ygO$)nVCvNGnq(6;bHlBd zl?w-|plD8spjDF03g5ip;W3Z z><0{BCq!Dw;h5~#1BuQilq*TwEu)qy50@+BE4bX28+7erX{BD4H)N+7U`AVEuREE8 z;X?~fyhF-x_sRfHIj~6f(+^@H)D=ngP;mwJjxhQUbUdzk8f94Ab%59-eRIq?ZKrwD z(BFI=)xrUlgu(b|hAysqK<}8bslmNNeD=#JW*}^~Nrswn^xw*nL@Tx!49bfJecV&KC2G4q5a!NSv)06A_5N3Y?veAz;Gv+@U3R% z)~UA8-0LvVE{}8LVDOHzp~2twReqf}ODIyXMM6=W>kL|OHcx9P%+aJGYi_Om)b!xe zF40Vntn0+VP>o<$AtP&JANjXBn7$}C@{+@3I@cqlwR2MdwGhVPxlTIcRVu@Ho-wO` z_~Or~IMG)A_`6-p)KPS@cT9mu9RGA>dVh5wY$NM9-^c@N=hcNaw4ITjm;iWSP^ZX| z)_XpaI61<+La+U&&%2a z0za$)-wZP@mwSELo#3!PGTt$uy0C(nTT@9NX*r3Ctw6J~7A(m#8fE)0RBd`TdKfAT zCf@$MAxjP`O(u9s@c0Fd@|}UQ6qp)O5Q5DPCeE6mSIh|Rj{$cAVIWsA=xPKVKxdhg zLzPZ`3CS+KIO;T}0Ip!fAUaNU>++ZJZRk@I(h<)RsJUhZ&Ru9*!4Ptn;gX^~4E8W^TSR&~3BAZc#HquXn)OW|TJ`CTahk+{qe`5+ixON^zA9IFd8)kc%*!AiLu z>`SFoZ5bW-%7}xZ>gpJcx_hpF$2l+533{gW{a7ce^B9sIdmLrI0)4yivZ^(Vh@-1q zFT!NQK$Iz^xu%|EOK=n>ug;(7J4OnS$;yWmq>A;hsD_0oAbLYhW^1Vdt9>;(JIYjf zdb+&f&D4@4AS?!*XpH>8egQvSVX`36jMd>$+RgI|pEg))^djhGSo&#lhS~9%NuWfX zDDH;3T*GzRT@5=7ibO>N-6_XPBYxno@mD_3I#rDD?iADxX`! zh*v8^i*JEMzyN#bGEBz7;UYXki*Xr(9xXax(_1qVW=Ml)kSuvK$coq2A(5ZGhs_pF z$*w}FbN6+QDseuB9=fdp_MTs)nQf!2SlROQ!gBJBCXD&@-VurqHj0wm@LWX-TDmS= z71M__vAok|@!qgi#H&H%Vg-((ZfxPAL8AI{x|VV!9)ZE}_l>iWk8UPTGHs*?u7RfP z5MC&=c6X;XlUzrz5q?(!eO@~* zoh2I*%J7dF!!_!vXoSIn5o|wj1#_>K*&CIn{qSaRc&iFVxt*^20ngCL;QonIS>I5^ zMw8HXm>W0PGd*}Ko)f|~dDd%;Wu_RWI_d;&2g6R3S63Uzjd7dn%Svu-OKpx*o|N>F zZg=-~qLb~VRLpv`k zWSdfHh@?dp=s_X`{yxOlxE$4iuyS;Z-x!*E6eqmEm*j2bE@=ZI0YZ5%Yj29!5+J$4h{s($nakA`xgbO8w zi=*r}PWz#lTL_DSAu1?f%-2OjD}NHXp4pXOsCW;DS@BC3h-q4_l`<))8WgzkdXg3! zs1WMt32kS2E#L0p_|x+x**TFV=gn`m9BWlzF{b%6j-odf4{7a4y4Uaef@YaeuPhU8 zHBvRqN^;$Jizy+ z=zW{E5<>2gp$pH{M@S*!sJVQU)b*J5*bX4h>5VJve#Q6ga}cQ&iL#=(u+KroWrxa%8&~p{WEUF0il=db;-$=A;&9M{Rq`ouZ5m%BHT6%st%saGsD6)fQgLN}x@d3q>FC;=f%O3Cyg=Ke@Gh`XW za@RajqOE9UB6eE=zhG%|dYS)IW)&y&Id2n7r)6p_)vlRP7NJL(x4UbhlcFXWT8?K=%s7;z?Vjts?y2+r|uk8Wt(DM*73^W%pAkZa1Jd zNoE)8FvQA>Z`eR5Z@Ig6kS5?0h;`Y&OL2D&xnnAUzQz{YSdh0k zB3exx%A2TyI)M*EM6htrxSlep!Kk(P(VP`$p0G~f$smld6W1r_Z+o?=IB@^weq>5VYsYZZR@` z&XJFxd5{|KPZmVOSxc@^%71C@;z}}WhbF9p!%yLj3j%YOlPL5s>7I3vj25 z@xmf=*z%Wb4;Va6SDk9cv|r*lhZ`(y_*M@>q;wrn)oQx%B(2A$9(74>;$zmQ!4fN; z>XurIk-7@wZys<+7XL@0Fhe-f%*=(weaQEdR9Eh6>Kl-EcI({qoZqyzziGwpg-GM#251sK_ z=3|kitS!j%;fpc@oWn65SEL73^N&t>Ix37xgs= zYG%eQDJc|rqHFia0!_sm7`@lvcv)gfy(+KXA@E{3t1DaZ$DijWAcA)E0@X?2ziJ{v z&KOYZ|DdkM{}t+@{@*6ge}m%xfjIxi%qh`=^2Rwz@w0cCvZ&Tc#UmCDbVwABrON^x zEBK43FO@weA8s7zggCOWhMvGGE`baZ62cC)VHyy!5Zbt%ieH+XN|OLbAFPZWyC6)p z4P3%8sq9HdS3=ih^0OOlqTPbKuzQ?lBEI{w^ReUO{V?@`ARsL|S*%yOS=Z%sF)>-y z(LAQdhgAcuF6LQjRYfdbD1g4o%tV4EiK&ElLB&^VZHbrV1K>tHTO{#XTo>)2UMm`2 z^t4s;vnMQgf-njU-RVBRw0P0-m#d-u`(kq7NL&2T)TjI_@iKuPAK-@oH(J8?%(e!0Ir$yG32@CGUPn5w4)+9@8c&pGx z+K3GKESI4*`tYlmMHt@br;jBWTei&(a=iYslc^c#RU3Q&sYp zSG){)V<(g7+8W!Wxeb5zJb4XE{I|&Y4UrFWr%LHkdQ;~XU zgy^dH-Z3lmY+0G~?DrC_S4@=>0oM8Isw%g(id10gWkoz2Q%7W$bFk@mIzTCcIB(K8 zc<5h&ZzCdT=9n-D>&a8vl+=ZF*`uTvQviG_bLde*k>{^)&0o*b05x$MO3gVLUx`xZ z43j+>!u?XV)Yp@MmG%Y`+COH2?nQcMrQ%k~6#O%PeD_WvFO~Kct za4XoCM_X!c5vhRkIdV=xUB3xI2NNStK*8_Zl!cFjOvp-AY=D;5{uXj}GV{LK1~IE2 z|KffUiBaStRr;10R~K2VVtf{TzM7FaPm;Y(zQjILn+tIPSrJh&EMf6evaBKIvi42-WYU9Vhj~3< zZSM-B;E`g_o8_XTM9IzEL=9Lb^SPhe(f(-`Yh=X6O7+6ALXnTcUFpI>ekl6v)ZQeNCg2 z^H|{SKXHU*%nBQ@I3It0m^h+6tvI@FS=MYS$ZpBaG7j#V@P2ZuYySbp@hA# ze(kc;P4i_-_UDP?%<6>%tTRih6VBgScKU^BV6Aoeg6Uh(W^#J^V$Xo^4#Ekp ztqQVK^g9gKMTHvV7nb64UU7p~!B?>Y0oFH5T7#BSW#YfSB@5PtE~#SCCg3p^o=NkMk$<8- z6PT*yIKGrvne7+y3}_!AC8NNeI?iTY(&nakN>>U-zT0wzZf-RuyZk^X9H-DT_*wk= z;&0}6LsGtfVa1q)CEUPlx#(ED@-?H<1_FrHU#z5^P3lEB|qsxEyn%FOpjx z3S?~gvoXy~L(Q{Jh6*i~=f%9kM1>RGjBzQh_SaIDfSU_9!<>*Pm>l)cJD@wlyxpBV z4Fmhc2q=R_wHCEK69<*wG%}mgD1=FHi4h!98B-*vMu4ZGW~%IrYSLGU{^TuseqVgV zLP<%wirIL`VLyJv9XG_p8w@Q4HzNt-o;U@Au{7%Ji;53!7V8Rv0^Lu^Vf*sL>R(;c zQG_ZuFl)Mh-xEIkGu}?_(HwkB2jS;HdPLSxVU&Jxy9*XRG~^HY(f0g8Q}iqnVmgjI zfd=``2&8GsycjR?M%(zMjn;tn9agcq;&rR!Hp z$B*gzHsQ~aXw8c|a(L^LW(|`yGc!qOnV(ZjU_Q-4z1&0;jG&vAKuNG=F|H?@m5^N@ zq{E!1n;)kNTJ>|Hb2ODt-7U~-MOIFo%9I)_@7fnX+eMMNh>)V$IXesJpBn|uo8f~#aOFytCT zf9&%MCLf8mp4kwHTcojWmM3LU=#|{3L>E}SKwOd?%{HogCZ_Z1BSA}P#O(%H$;z7XyJ^sjGX;j5 zrzp>|Ud;*&VAU3x#f{CKwY7Vc{%TKKqmB@oTHA9;>?!nvMA;8+Jh=cambHz#J18x~ zs!dF>$*AnsQ{{82r5Aw&^7eRCdvcgyxH?*DV5(I$qXh^zS>us*I66_MbL8y4d3ULj z{S(ipo+T3Ag!+5`NU2sc+@*m{_X|&p#O-SAqF&g_n7ObB82~$p%fXA5GLHMC+#qqL zdt`sJC&6C2)=juQ_!NeD>U8lDVpAOkW*khf7MCcs$A(wiIl#B9HM%~GtQ^}yBPjT@ z+E=|A!Z?A(rwzZ;T}o6pOVqHzTr*i;Wrc%&36kc@jXq~+w8kVrs;%=IFdACoLAcCAmhFNpbP8;s`zG|HC2Gv?I~w4ITy=g$`0qMQdkijLSOtX6xW%Z9Nw<;M- zMN`c7=$QxN00DiSjbVt9Mi6-pjv*j(_8PyV-il8Q-&TwBwH1gz1uoxs6~uU}PrgWB zIAE_I-a1EqlIaGQNbcp@iI8W1sm9fBBNOk(k&iLBe%MCo#?xI$%ZmGA?=)M9D=0t7 zc)Q0LnI)kCy{`jCGy9lYX%mUsDWwsY`;jE(;Us@gmWPqjmXL+Hu#^;k%eT>{nMtzj zsV`Iy6leTA8-PndszF;N^X@CJrTw5IIm!GPeu)H2#FQitR{1p;MasQVAG3*+=9FYK zw*k!HT(YQorfQj+1*mCV458(T5=fH`um$gS38hw(OqVMyunQ;rW5aPbF##A3fGH6h z@W)i9Uff?qz`YbK4c}JzQpuxuE3pcQO)%xBRZp{zJ^-*|oryTxJ-rR+MXJ)!f=+pp z10H|DdGd2exhi+hftcYbM0_}C0ZI-2vh+$fU1acsB-YXid7O|=9L!3e@$H*6?G*Zp z%qFB(sgl=FcC=E4CYGp4CN>=M8#5r!RU!u+FJVlH6=gI5xHVD&k;Ta*M28BsxfMV~ zLz+@6TxnfLhF@5=yQo^1&S}cmTN@m!7*c6z;}~*!hNBjuE>NLVl2EwN!F+)0$R1S! zR|lF%n!9fkZ@gPW|x|B={V6x3`=jS*$Pu0+5OWf?wnIy>Y1MbbGSncpKO0qE(qO=ts z!~@&!N`10S593pVQu4FzpOh!tvg}p%zCU(aV5=~K#bKi zHdJ1>tQSrhW%KOky;iW+O_n;`l9~omqM%sdxdLtI`TrJzN6BQz+7xOl*rM>xVI2~# z)7FJ^Dc{DC<%~VS?@WXzuOG$YPLC;>#vUJ^MmtbSL`_yXtNKa$Hk+l-c!aC7gn(Cg ze?YPYZ(2Jw{SF6MiO5(%_pTo7j@&DHNW`|lD`~{iH+_eSTS&OC*2WTT*a`?|9w1dh zh1nh@$a}T#WE5$7Od~NvSEU)T(W$p$s5fe^GpG+7fdJ9=enRT9$wEk+ZaB>G3$KQO zgq?-rZZnIv!p#>Ty~}c*Lb_jxJg$eGM*XwHUwuQ|o^}b3^T6Bxx{!?va8aC@-xK*H ztJBFvFfsSWu89%@b^l3-B~O!CXs)I6Y}y#0C0U0R0WG zybjroj$io0j}3%P7zADXOwHwafT#uu*zfM!oD$6aJx7+WL%t-@6^rD_a_M?S^>c;z zMK580bZXo1f*L$CuMeM4Mp!;P@}b~$cd(s5*q~FP+NHSq;nw3fbWyH)i2)-;gQl{S zZO!T}A}fC}vUdskGSq&{`oxt~0i?0xhr6I47_tBc`fqaSrMOzR4>0H^;A zF)hX1nfHs)%Zb-(YGX;=#2R6C{BG;k=?FfP?9{_uFLri~-~AJ;jw({4MU7e*d)?P@ zXX*GkNY9ItFjhwgAIWq7Y!ksbMzfqpG)IrqKx9q{zu%Mdl+{Dis#p9q`02pr1LG8R z@As?eG!>IoROgS!@J*to<27coFc1zpkh?w=)h9CbYe%^Q!Ui46Y*HO0mr% zEff-*$ndMNw}H2a5@BsGj5oFfd!T(F&0$<{GO!Qdd?McKkorh=5{EIjDTHU`So>8V zBA-fqVLb2;u7UhDV1xMI?y>fe3~4urv3%PX)lDw+HYa;HFkaLqi4c~VtCm&Ca+9C~ zge+67hp#R9`+Euq59WhHX&7~RlXn=--m8$iZ~~1C8cv^2(qO#X0?vl91gzUKBeR1J z^p4!!&7)3#@@X&2aF2-)1Ffcc^F8r|RtdL2X%HgN&XU-KH2SLCbpw?J5xJ*!F-ypZ zMG%AJ!Pr&}`LW?E!K~=(NJxuSVTRCGJ$2a*Ao=uUDSys!OFYu!Vs2IT;xQ6EubLIl z+?+nMGeQQhh~??0!s4iQ#gm3!BpMpnY?04kK375e((Uc7B3RMj;wE?BCoQGu=UlZt!EZ1Q*auI)dj3Jj{Ujgt zW5hd~-HWBLI_3HuO) zNrb^XzPsTIb=*a69wAAA3J6AAZZ1VsYbIG}a`=d6?PjM)3EPaDpW2YP$|GrBX{q*! z$KBHNif)OKMBCFP5>!1d=DK>8u+Upm-{hj5o|Wn$vh1&K!lVfDB&47lw$tJ?d5|=B z^(_9=(1T3Fte)z^>|3**n}mIX;mMN5v2F#l(q*CvU{Ga`@VMp#%rQkDBy7kYbmb-q z<5!4iuB#Q_lLZ8}h|hPODI^U6`gzLJre9u3k3c#%86IKI*^H-@I48Bi*@avYm4v!n0+v zWu{M{&F8#p9cx+gF0yTB_<2QUrjMPo9*7^-uP#~gGW~y3nfPAoV%amgr>PSyVAd@l)}8#X zR5zV6t*uKJZL}?NYvPVK6J0v4iVpwiN|>+t3aYiZSp;m0!(1`bHO}TEtWR1tY%BPB z(W!0DmXbZAsT$iC13p4f>u*ZAy@JoLAkJhzFf1#4;#1deO8#8d&89}en&z!W&A3++^1(;>0SB1*54d@y&9Pn;^IAf3GiXbfT`_>{R+Xv; zQvgL>+0#8-laO!j#-WB~(I>l0NCMt_;@Gp_f0#^c)t?&#Xh1-7RR0@zPyBz!U#0Av zT?}n({(p?p7!4S2ZBw)#KdCG)uPnZe+U|0{BW!m)9 zi_9$F?m<`2!`JNFv+w8MK_K)qJ^aO@7-Ig>cM4-r0bi=>?B_2mFNJ}aE3<+QCzRr*NA!QjHw# z`1OsvcoD0?%jq{*7b!l|L1+Tw0TTAM4XMq7*ntc-Ived>Sj_ZtS|uVdpfg1_I9knY z2{GM_j5sDC7(W&}#s{jqbybqJWyn?{PW*&cQIU|*v8YGOKKlGl@?c#TCnmnAkAzV- zmK={|1G90zz=YUvC}+fMqts0d4vgA%t6Jhjv?d;(Z}(Ep8fTZfHA9``fdUHkA+z3+ zhh{ohP%Bj?T~{i0sYCQ}uC#5BwN`skI7`|c%kqkyWIQ;!ysvA8H`b-t()n6>GJj6xlYDu~8qX{AFo$Cm3d|XFL=4uvc?Keb zzb0ZmMoXca6Mob>JqkNuoP>B2Z>D`Q(TvrG6m`j}-1rGP!g|qoL=$FVQYxJQjFn33lODt3Wb1j8VR zlR++vIT6^DtYxAv_hxupbLLN3e0%A%a+hWTKDV3!Fjr^cWJ{scsAdfhpI)`Bms^M6 zQG$waKgFr=c|p9Piug=fcJvZ1ThMnNhQvBAg-8~b1?6wL*WyqXhtj^g(Ke}mEfZVM zJuLNTUVh#WsE*a6uqiz`b#9ZYg3+2%=C(6AvZGc=u&<6??!slB1a9K)=VL zY9EL^mfyKnD zSJyYBc_>G;5RRnrNgzJz#Rkn3S1`mZgO`(r5;Hw6MveN(URf_XS-r58Cn80K)ArH4 z#Rrd~LG1W&@ttw85cjp8xV&>$b%nSXH_*W}7Ch2pg$$c0BdEo-HWRTZcxngIBJad> z;C>b{jIXjb_9Jis?NZJsdm^EG}e*pR&DAy0EaSGi3XWTa(>C%tz1n$u?5Fb z1qtl?;_yjYo)(gB^iQq?=jusF%kywm?CJP~zEHi0NbZ);$(H$w(Hy@{i>$wcVRD_X|w-~(0Z9BJyh zhNh;+eQ9BEIs;tPz%jSVnfCP!3L&9YtEP;svoj_bNzeGSQIAjd zBss@A;)R^WAu-37RQrM%{DfBNRx>v!G31Z}8-El9IOJlb_MSoMu2}GDYycNaf>uny z+8xykD-7ONCM!APry_Lw6-yT>5!tR}W;W`C)1>pxSs5o1z#j7%m=&=7O4hz+Lsqm` z*>{+xsabZPr&X=}G@obTb{nPTkccJX8w3CG7X+1+t{JcMabv~UNv+G?txRqXib~c^Mo}`q{$`;EBNJ;#F*{gvS12kV?AZ%O0SFB$^ zn+}!HbmEj}w{Vq(G)OGAzH}R~kS^;(-s&=ectz8vN!_)Yl$$U@HNTI-pV`LSj7Opu zTZ5zZ)-S_{GcEQPIQXLQ#oMS`HPu{`SQiAZ)m1at*Hy%3xma|>o`h%E%8BEbi9p0r zVjcsh<{NBKQ4eKlXU|}@XJ#@uQw*$4BxKn6#W~I4T<^f99~(=}a`&3(ur8R9t+|AQ zWkQx7l}wa48-jO@ft2h+7qn%SJtL%~890FG0s5g*kNbL3I&@brh&f6)TlM`K^(bhr zJWM6N6x3flOw$@|C@kPi7yP&SP?bzP-E|HSXQXG>7gk|R9BTj`e=4de9C6+H7H7n# z#GJeVs1mtHhLDmVO?LkYRQc`DVOJ_vdl8VUihO-j#t=0T3%Fc1f9F73ufJz*adn*p zc%&vi(4NqHu^R>sAT_0EDjVR8bc%wTz#$;%NU-kbDyL_dg0%TFafZwZ?5KZpcuaO54Z9hX zD$u>q!-9`U6-D`E#`W~fIfiIF5_m6{fvM)b1NG3xf4Auw;Go~Fu7cth#DlUn{@~yu z=B;RT*dp?bO}o%4x7k9v{r=Y@^YQ^UUm(Qmliw8brO^=NP+UOohLYiaEB3^DB56&V zK?4jV61B|1Uj_5fBKW;8LdwOFZKWp)g{B%7g1~DgO&N& z#lisxf?R~Z@?3E$Mms$$JK8oe@X`5m98V*aV6Ua}8Xs2#A!{x?IP|N(%nxsH?^c{& z@vY&R1QmQs83BW28qAmJfS7MYi=h(YK??@EhjL-t*5W!p z^gYX!Q6-vBqcv~ruw@oMaU&qp0Fb(dbVzm5xJN%0o_^@fWq$oa3X?9s%+b)x4w-q5Koe(@j6Ez7V@~NRFvd zfBH~)U5!ix3isg`6be__wBJp=1@yfsCMw1C@y+9WYD9_C%{Q~7^0AF2KFryfLlUP# zwrtJEcH)jm48!6tUcxiurAMaiD04C&tPe6DI0#aoqz#Bt0_7_*X*TsF7u*zv(iEfA z;$@?XVu~oX#1YXtceQL{dSneL&*nDug^OW$DSLF0M1Im|sSX8R26&)<0Fbh^*l6!5wfSu8MpMoh=2l z^^0Sr$UpZp*9oqa23fcCfm7`ya2<4wzJ`Axt7e4jJrRFVf?nY~2&tRL* zd;6_njcz01c>$IvN=?K}9ie%Z(BO@JG2J}fT#BJQ+f5LFSgup7i!xWRKw6)iITjZU z%l6hPZia>R!`aZjwCp}I zg)%20;}f+&@t;(%5;RHL>K_&7MH^S+7<|(SZH!u zznW|jz$uA`P9@ZWtJgv$EFp>)K&Gt+4C6#*khZQXS*S~6N%JDT$r`aJDs9|uXWdbg zBwho$phWx}x!qy8&}6y5Vr$G{yGSE*r$^r{}pw zVTZKvikRZ`J_IJrjc=X1uw?estdwm&bEahku&D04HD+0Bm~q#YGS6gp!KLf$A{%Qd z&&yX@Hp>~(wU{|(#U&Bf92+1i&Q*-S+=y=3pSZy$#8Uc$#7oiJUuO{cE6=tsPhwPe| zxQpK>`Dbka`V)$}e6_OXKLB%i76~4N*zA?X+PrhH<&)}prET;kel24kW%+9))G^JI zsq7L{P}^#QsZViX%KgxBvEugr>ZmFqe^oAg?{EI=&_O#e)F3V#rc z8$4}0Zr19qd3tE4#$3_f=Bbx9oV6VO!d3(R===i-7p=Vj`520w0D3W6lQfY48}!D* z&)lZMG;~er2qBoI2gsX+Ts-hnpS~NYRDtPd^FPzn!^&yxRy#CSz(b&E*tL|jIkq|l zf%>)7Dtu>jCf`-7R#*GhGn4FkYf;B$+9IxmqH|lf6$4irg{0ept__%)V*R_OK=T06 zyT_m-o@Kp6U{l5h>W1hGq*X#8*y@<;vsOFqEjTQXFEotR+{3}ODDnj;o0@!bB5x=N z394FojuGOtVKBlVRLtHp%EJv_G5q=AgF)SKyRN5=cGBjDWv4LDn$IL`*=~J7u&Dy5 zrMc83y+w^F&{?X(KOOAl-sWZDb{9X9#jrQtmrEXD?;h-}SYT7yM(X_6qksM=K_a;Z z3u0qT0TtaNvDER_8x*rxXw&C^|h{P1qxK|@pS7vdlZ#P z7PdB7MmC2}%sdzAxt>;WM1s0??`1983O4nFK|hVAbHcZ3x{PzytQLkCVk7hA!Lo` zEJH?4qw|}WH{dc4z%aB=0XqsFW?^p=X}4xnCJXK%c#ItOSjdSO`UXJyuc8bh^Cf}8 z@Ht|vXd^6{Fgai8*tmyRGmD_s_nv~r^Fy7j`Bu`6=G)5H$i7Q7lvQnmea&TGvJp9a|qOrUymZ$6G|Ly z#zOCg++$3iB$!6!>215A4!iryregKuUT344X)jQb3|9qY>c0LO{6Vby05n~VFzd?q zgGZv&FGlkiH*`fTurp>B8v&nSxNz)=5IF$=@rgND4d`!AaaX;_lK~)-U8la_Wa8i?NJC@BURO*sUW)E9oyv3RG^YGfN%BmxzjlT)bp*$<| zX3tt?EAy<&K+bhIuMs-g#=d1}N_?isY)6Ay$mDOKRh z4v1asEGWoAp=srraLW^h&_Uw|6O+r;wns=uwYm=JN4Q!quD8SQRSeEcGh|Eb5Jg8m zOT}u;N|x@aq)=&;wufCc^#)5U^VcZw;d_wwaoh9$p@Xrc{DD6GZUqZ ziC6OT^zSq@-lhbgR8B+e;7_Giv;DK5gn^$bs<6~SUadiosfewWDJu`XsBfOd1|p=q zE>m=zF}!lObA%ePey~gqU8S6h-^J2Y?>7)L2+%8kV}Gp=h`Xm_}rlm)SyUS=`=S7msKu zC|T!gPiI1rWGb1z$Md?0YJQ;%>uPLOXf1Z>N~`~JHJ!^@D5kSXQ4ugnFZ>^`zH8CAiZmp z6Ms|#2gcGsQ{{u7+Nb9sA?U>(0e$5V1|WVwY`Kn)rsnnZ4=1u=7u!4WexZD^IQ1Jk zfF#NLe>W$3m&C^ULjdw+5|)-BSHwpegdyt9NYC{3@QtMfd8GrIWDu`gd0nv-3LpGCh@wgBaG z176tikL!_NXM+Bv#7q^cyn9$XSeZR6#!B4JE@GVH zoobHZN_*RF#@_SVYKkQ_igme-Y5U}cV(hkR#k1c{bQNMji zU7aE`?dHyx=1`kOYZo_8U7?3-7vHOp`Qe%Z*i+FX!s?6huNp0iCEW-Z7E&jRWmUW_ z67j>)Ew!yq)hhG4o?^z}HWH-e=es#xJUhDRc4B51M4~E-l5VZ!&zQq`gWe`?}#b~7w1LH4Xa-UCT5LXkXQWheBa2YJYbyQ zl1pXR%b(KCXMO0OsXgl0P0Og<{(@&z1aokU-Pq`eQq*JYgt8xdFQ6S z6Z3IFSua8W&M#`~*L#r>Jfd6*BzJ?JFdBR#bDv$_0N!_5vnmo@!>vULcDm`MFU823 zpG9pqjqz^FE5zMDoGqhs5OMmC{Y3iVcl>F}5Rs24Y5B^mYQ;1T&ks@pIApHOdrzXF z-SdX}Hf{X;TaSxG_T$0~#RhqKISGKNK47}0*x&nRIPtmdwxc&QT3$8&!3fWu1eZ_P zJveQj^hJL#Sn!*4k`3}(d(aasl&7G0j0-*_2xtAnoX1@9+h zO#c>YQg60Z;o{Bi=3i7S`Ic+ZE>K{(u|#)9y}q*j8uKQ1^>+(BI}m%1v3$=4ojGBc zm+o1*!T&b}-lVvZqIUBc8V}QyFEgm#oyIuC{8WqUNV{Toz`oxhYpP!_p2oHHh5P@iB*NVo~2=GQm+8Yrkm2Xjc_VyHg1c0>+o~@>*Qzo zHVBJS>$$}$_4EniTI;b1WShX<5-p#TPB&!;lP!lBVBbLOOxh6FuYloD%m;n{r|;MU3!q4AVkua~fieeWu2 zQAQ$ue(IklX6+V;F1vCu-&V?I3d42FgWgsb_e^29ol}HYft?{SLf>DrmOp9o!t>I^ zY7fBCk+E8n_|apgM|-;^=#B?6RnFKlN`oR)`e$+;D=yO-(U^jV;rft^G_zl`n7qnM zL z*-Y4Phq+ZI1$j$F-f;`CD#|`-T~OM5Q>x}a>B~Gb3-+9i>Lfr|Ca6S^8g*{*?_5!x zH_N!SoRP=gX1?)q%>QTY!r77e2j9W(I!uAz{T`NdNmPBBUzi2{`XMB^zJGGwFWeA9 z{fk33#*9SO0)DjROug+(M)I-pKA!CX;IY(#gE!UxXVsa)X!UftIN98{pt#4MJHOhY zM$_l}-TJlxY?LS6Nuz1T<44m<4i^8k@D$zuCPrkmz@sdv+{ciyFJG2Zwy&%c7;atIeTdh!a(R^QXnu1Oq1b42*OQFWnyQ zWeQrdvP|w_idy53Wa<{QH^lFmEd+VlJkyiC>6B#s)F;w-{c;aKIm;Kp50HnA-o3lY z9B~F$gJ@yYE#g#X&3ADx&tO+P_@mnQTz9gv30_sTsaGXkfNYXY{$(>*PEN3QL>I!k zp)KibPhrfX3%Z$H6SY`rXGYS~143wZrG2;=FLj50+VM6soI~up_>fU(2Wl@{BRsMi zO%sL3x?2l1cXTF)k&moNsHfQrQ+wu(gBt{sk#CU=UhrvJIncy@tJX5klLjgMn>~h= zg|FR&;@eh|C7`>s_9c~0-{IAPV){l|Ts`i=)AW;d9&KPc3fMeoTS%8@V~D8*h;&(^>yjT84MM}=%#LS7shLAuuj(0VAYoozhWjq z4LEr?wUe2^WGwdTIgWBkDUJa>YP@5d9^Rs$kCXmMRxuF*YMVrn?0NFyPl}>`&dqZb z<5eqR=ZG3>n2{6v6BvJ`YBZeeTtB88TAY(x0a58EWyuf>+^|x8Qa6wA|1Nb_p|nA zWWa}|z8a)--Wj`LqyFk_a3gN2>5{Rl_wbW?#by7&i*^hRknK%jwIH6=dQ8*-_{*x0j^DUfMX0`|K@6C<|1cgZ~D(e5vBFFm;HTZF(!vT8=T$K+|F)x3kqzBV4-=p1V(lzi(s7jdu0>LD#N=$Lk#3HkG!a zIF<7>%B7sRNzJ66KrFV76J<2bdYhxll0y2^_rdG=I%AgW4~)1Nvz=$1UkE^J%BxLo z+lUci`UcU062os*=`-j4IfSQA{w@y|3}Vk?i;&SSdh8n+$iHA#%ERL{;EpXl6u&8@ zzg}?hkEOUOJt?ZL=pWZFJ19mI1@P=$U5*Im1e_8Z${JsM>Ov?nh8Z zP5QvI!{Jy@&BP48%P2{Jr_VgzW;P@7)M9n|lDT|Ep#}7C$&ud&6>C^5ZiwKIg2McPU(4jhM!BD@@L(Gd*Nu$ji(ljZ<{FIeW_1Mmf;76{LU z-ywN~=uNN)Xi6$<12A9y)K%X|(W0p|&>>4OXB?IiYr||WKDOJPxiSe01NSV-h24^L z_>m$;|C+q!Mj**-qQ$L-*++en(g|hw;M!^%_h-iDjFHLo-n3JpB;p?+o2;`*jpvJU zLY^lt)Un4joij^^)O(CKs@7E%*!w>!HA4Q?0}oBJ7Nr8NQ7QmY^4~jvf0-`%waOLn zdNjAPaC0_7c|RVhw)+71NWjRi!y>C+Bl;Z`NiL^zn2*0kmj5gyhCLCxts*cWCdRI| zjsd=sT5BVJc^$GxP~YF$-U{-?kW6r@^vHXB%{CqYzU@1>dzf#3SYedJG-Rm6^RB7s zGM5PR(yKPKR)>?~vpUIeTP7A1sc8-knnJk*9)3t^e%izbdm>Y=W{$wm(cy1RB-19i za#828DMBY+ps#7Y8^6t)=Ea@%Nkt)O6JCx|ybC;Ap}Z@Zw~*}3P>MZLPb4Enxz9Wf zssobT^(R@KuShj8>@!1M7tm|2%-pYYDxz-5`rCbaTCG5{;Uxm z*g=+H1X8{NUvFGzz~wXa%Eo};I;~`37*WrRU&K0dPSB$yk(Z*@K&+mFal^?c zurbqB-+|Kb5|sznT;?Pj!+kgFY1#Dr;_%A(GIQC{3ct|{*Bji%FNa6c-thbpBkA;U zURV!Dr&X{0J}iht#-Qp2=xzuh(fM>zRoiGrYl5ttw2#r34gC41CCOC31m~^UPTK@s z6;A@)7O7_%C)>bnAXerYuAHdE93>j2N}H${zEc6&SbZ|-fiG*-qtGuy-qDelH(|u$ zorf8_T6Zqe#Ub!+e3oSyrskt_HyW_^5lrWt#30l)tHk|j$@YyEkXUOV;6B51L;M@=NIWZXU;GrAa(LGxO%|im%7F<-6N;en0Cr zLH>l*y?pMwt`1*cH~LdBPFY_l;~`N!Clyfr;7w<^X;&(ZiVdF1S5e(+Q%60zgh)s4 zn2yj$+mE=miVERP(g8}G4<85^-5f@qxh2ec?n+$A_`?qN=iyT1?U@t?V6DM~BIlBB z>u~eXm-aE>R0sQy!-I4xtCNi!!qh?R1!kKf6BoH2GG{L4%PAz0{Sh6xpuyI%*~u)s z%rLuFl)uQUCBQAtMyN;%)zFMx4loh7uTfKeB2Xif`lN?2gq6NhWhfz0u5WP9J>=V2 zo{mLtSy&BA!mSzs&CrKWq^y40JF5a&GSXIi2= z{EYb59J4}VwikL4P=>+mc6{($FNE@e=VUwG+KV21;<@lrN`mnz5jYGASyvz7BOG_6(p^eTxD-4O#lROgon;R35=|nj#eHIfJBYPWG>H>`dHKCDZ3`R{-?HO0mE~(5_WYcFmp8sU?wr*UkAQiNDGc6T zA%}GOLXlOWqL?WwfHO8MB#8M8*~Y*gz;1rWWoVSXP&IbKxbQ8+s%4Jnt?kDsq7btI zCDr0PZ)b;B%!lu&CT#RJzm{l{2fq|BcY85`w~3LSK<><@(2EdzFLt9Y_`;WXL6x`0 zDoQ?=?I@Hbr;*VVll1Gmd8*%tiXggMK81a+T(5Gx6;eNb8=uYn z5BG-0g>pP21NPn>$ntBh>`*})Fl|38oC^9Qz>~MAazH%3Q~Qb!ALMf$srexgPZ2@&c~+hxRi1;}+)-06)!#Mq<6GhP z-Q?qmgo${aFBApb5p}$1OJKTClfi8%PpnczyVKkoHw7Ml9e7ikrF0d~UB}i3vizos zXW4DN$SiEV9{faLt5bHy2a>33K%7Td-n5C*N;f&ZqAg#2hIqEb(y<&f4u5BWJ>2^4 z414GosL=Aom#m&=x_v<0-fp1r%oVJ{T-(xnomNJ(Dryv zh?vj+%=II_nV+@NR+(!fZZVM&(W6{6%9cm+o+Z6}KqzLw{(>E86uA1`_K$HqINlb1 zKelh3-jr2I9V?ych`{hta9wQ2c9=MM`2cC{m6^MhlL2{DLv7C^j z$xXBCnDl_;l|bPGMX@*tV)B!c|4oZyftUlP*?$YU9C_eAsuVHJ58?)zpbr30P*C`T z7y#ao`uE-SOG(Pi+`$=e^mle~)pRrdwL5)N;o{gpW21of(QE#U6w%*C~`v-z0QqBML!!5EeYA5IQB0 z^l01c;L6E(iytN!LhL}wfwP7W9PNAkb+)Cst?qg#$n;z41O4&v+8-zPs+XNb-q zIeeBCh#ivnFLUCwfS;p{LC0O7tm+Sf9Jn)~b%uwP{%69;QC)Ok0t%*a5M+=;y8j=v z#!*pp$9@!x;UMIs4~hP#pnfVc!%-D<+wsG@R2+J&%73lK|2G!EQC)O05TCV=&3g)C!lT=czLpZ@Sa%TYuoE?v8T8`V;e$#Zf2_Nj6nvBgh1)2 GZ~q4|mN%#X literal 0 HcmV?d00001 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) }