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/<device>/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 <noreply@paperclip.ing>
This commit is contained in:
71
android/app/build.gradle.kts
Normal file
71
android/app/build.gradle.kts
Normal file
@@ -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")
|
||||
}
|
||||
3
android/app/proguard-rules.pro
vendored
Normal file
3
android/app/proguard-rules.pro
vendored
Normal file
@@ -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.**
|
||||
68
android/app/src/main/AndroidManifest.xml
Normal file
68
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Kids' Android client (AGENTS.md §11 Phase 5, FR-16 + FR-17).
|
||||
|
||||
No Device Owner, no factory reset, no managed profile. The app is an ordinary
|
||||
app that asks for the Accessibility Service and the overlay permission. The
|
||||
child can disable it; the enforcement is the tamper alert, not the overlay.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- The full-screen block overlay is a TYPE_APPLICATION_OVERLAY window. -->
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<!-- The watchdog keeps a config connection alive and reports tampering. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application
|
||||
android:name=".KidsApp"
|
||||
android:allowBackup="false"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.WebRestrict"
|
||||
android:usesCleartextTraffic="false">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- FR-16: the foreground-app watcher. Disabling it raises a tamper
|
||||
alert (FR-17). It never changes its own rules; it obeys the config
|
||||
the Control Service publishes. -->
|
||||
<service
|
||||
android:name=".BlockAccessibilityService"
|
||||
android:exported="true"
|
||||
android:label="@string/accessibility_label"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/accessibility_service_config" />
|
||||
</service>
|
||||
|
||||
<!-- FR-17: after a reboot, report that the device is back and reconnect
|
||||
to the broker so the retained config is fetched at once. -->
|
||||
<receiver
|
||||
android:name=".BootReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/<device>/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<String> = emptyList(),
|
||||
val block: List<String> = 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<String> = 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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/<device>/config` topic over the 8883 TLS
|
||||
* plane with the device's own credentials, and publishes presence plus the
|
||||
* tamper report on `restrict/<device>/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<X509Certificate> =
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
14
android/app/src/main/res/drawable/ic_launcher.xml
Normal file
14
android/app/src/main/res/drawable/ic_launcher.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- A minimal shield mark. No binary art in the repo. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#0075de"
|
||||
android:pathData="M54,14 L88,28 L88,54 C88,74 73,88 54,94 C35,88 20,74 20,54 L20,28 Z" />
|
||||
<path
|
||||
android:fillColor="#ffffff"
|
||||
android:pathData="M48,60 L40,52 L34,58 L48,72 L74,44 L68,38 Z" />
|
||||
</vector>
|
||||
9
android/app/src/main/res/values-night/themes.xml
Normal file
9
android/app/src/main/res/values-night/themes.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.WebRestrict" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="colorPrimary">#0075de</item>
|
||||
<item name="colorPrimaryDark">#0059a8</item>
|
||||
<item name="colorAccent">#0075de</item>
|
||||
<item name="android:windowBackground">#121212</item>
|
||||
</style>
|
||||
</resources>
|
||||
8
android/app/src/main/res/values/strings.xml
Normal file
8
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Screen Limits</string>
|
||||
<string name="accessibility_label">Screen Limits blocks apps</string>
|
||||
<string name="accessibility_description">Screen Limits uses this service to see which app is in the
|
||||
foreground, so it can block an app on the family blocklist while a limit is active. It does
|
||||
not read the screen or your typing.</string>
|
||||
</resources>
|
||||
10
android/app/src/main/res/values/themes.xml
Normal file
10
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Family Home Lab tokens: canvas #f6f5f4, action blue #0075de. -->
|
||||
<style name="Theme.WebRestrict" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="colorPrimary">#0075de</item>
|
||||
<item name="colorPrimaryDark">#0059a8</item>
|
||||
<item name="colorAccent">#0075de</item>
|
||||
<item name="android:windowBackground">#f6f5f4</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
The Accessibility Service contract. It watches window-state changes to learn
|
||||
the foreground package, and nothing else: canRetrieveWindowContent is false,
|
||||
so the app never reads the screen's text.
|
||||
-->
|
||||
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accessibilityEventTypes="typeWindowStateChanged"
|
||||
android:accessibilityFeedbackType="feedbackGeneric"
|
||||
android:accessibilityFlags="flagDefault"
|
||||
android:canRetrieveWindowContent="false"
|
||||
android:description="@string/accessibility_description"
|
||||
android:notificationTimeout="100"
|
||||
android:settingsActivity="au.audasmedia.webrestrict.kids.MainActivity" />
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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<String>(), config.allow)
|
||||
assertEquals(emptyList<String>(), 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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user