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:
2026-10-13 20:17:22 +11:00
parent 8c42daf4d9
commit e018c3bfa8
39 changed files with 2323 additions and 7 deletions

View File

@@ -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"
}

View File

@@ -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)
}
}
}