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:
@@ -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.
|
||||
|
||||
110
internal/mqtt/link_test.go
Normal file
110
internal/mqtt/link_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user