Files
Sam Rolfe e018c3bfa8 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>
2026-10-13 20:17:22 +11:00

298 lines
9.5 KiB
Go

// Package alert watches device state and tells Sam when something needs his
// attention (AGENTS.md §4 FR-13, §5 screen 6, §11 Phase 9).
//
// Two conditions raise an alert:
//
// - a limit is hit: a device becomes blocked (a weekly window turned on, or a
// manual lock was applied), and
// - a device is offline too long: presence is false and the last report is
// older than the configured threshold.
//
// Alerts fire once per transition, not once per poll. The last observed
// condition is kept in `alert_state`; the first time the engine sees a device it
// seeds that state without alerting, so a restart never replays an old limit.
//
// Delivery goes through Apprise. The `alerts` row is written before delivery, so
// a delivery failure leaves a visible, auditable record rather than silence.
package alert
import (
"context"
"fmt"
"log/slog"
"time"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/apprise"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
)
// DefaultOfflineAfter is how long a device may be unreachable before Sam is
// told. Overridable with CONTROL_ALERT_OFFLINE_AFTER.
const DefaultOfflineAfter = 30 * time.Minute
// DefaultInterval is how often the engine re-evaluates. It bounds how late an
// alert can be, and it is short enough that a limit hit feels immediate.
const DefaultInterval = 30 * time.Second
// Store is the data surface the engine needs. *store.Store satisfies it.
type Store interface {
ListDevices(ctx context.Context) ([]store.Device, error)
EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error)
AlertStates(ctx context.Context) (map[int64]store.AlertState, error)
SetAlertState(ctx context.Context, deviceID int64, locked, offline bool) error
CreateAlert(ctx context.Context, deviceID *int64, kind, message string) (*store.Alert, error)
MarkAlertDelivered(ctx context.Context, id int64, errMsg string) error
}
// Notifier delivers one alert. *apprise.Client satisfies it; a nil Notifier is
// not configured and the engine records the alert as undelivered.
type Notifier interface {
Configured() bool
SendType(ctx context.Context, title, body, notifyType string) error
}
// Engine evaluates device state on an interval and raises transition alerts.
type Engine struct {
Store Store
Notifier Notifier
Log *slog.Logger
OfflineAfter time.Duration
Interval time.Duration
Location *time.Location
// Now is injectable so tests are deterministic.
Now func() time.Time
}
// New builds an engine. A zero offlineAfter uses DefaultOfflineAfter; a zero
// interval uses DefaultInterval.
func New(st Store, notifier Notifier, log *slog.Logger, offlineAfter time.Duration, loc *time.Location) *Engine {
if offlineAfter <= 0 {
offlineAfter = DefaultOfflineAfter
}
if log == nil {
log = slog.Default()
}
if loc == nil {
loc = time.Local
}
return &Engine{
Store: st,
Notifier: notifier,
Log: log,
OfflineAfter: offlineAfter,
Interval: DefaultInterval,
Location: loc,
Now: func() time.Time { return time.Now().In(loc) },
}
}
// Run evaluates once at start-up (to seed state without alerting) and then on
// every tick until ctx is cancelled.
func (e *Engine) Run(ctx context.Context) {
interval := e.Interval
if interval <= 0 {
interval = DefaultInterval
}
e.Evaluate(ctx)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
e.Evaluate(ctx)
}
}
}
// Evaluate checks every device once and raises an alert for each condition that
// just became true.
func (e *Engine) Evaluate(ctx context.Context) {
now := e.now()
devs, err := e.Store.ListDevices(ctx)
if err != nil {
e.Log.Error("alert evaluate: list devices", "err", err)
return
}
states, err := e.Store.AlertStates(ctx)
if err != nil {
e.Log.Error("alert evaluate: alert states", "err", err)
return
}
for _, dev := range devs {
eff, err := e.Store.EffectiveState(ctx, dev, now)
if err != nil {
e.Log.Error("alert evaluate: effective state", "device", dev.Name, "err", err)
continue
}
locked := eff.Locked
offline := e.offlineTooLong(dev, now)
prev, known := states[dev.ID]
if !known {
// First observation: remember the condition, alert on nothing.
if err := e.Store.SetAlertState(ctx, dev.ID, locked, offline); err != nil {
e.Log.Error("alert seed state", "device", dev.Name, "err", err)
}
continue
}
if locked && !prev.Locked {
e.fire(ctx, dev, store.AlertLimit, limitMessage(dev, eff, e.loc()))
}
if offline && !prev.Offline {
e.fire(ctx, dev, store.AlertOffline, offlineMessage(dev, now))
}
if locked != prev.Locked || offline != prev.Offline {
if err := e.Store.SetAlertState(ctx, dev.ID, locked, offline); err != nil {
e.Log.Error("alert save state", "device", dev.Name, "err", err)
}
}
}
}
// 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) {
deviceID := dev.ID
alert, err := e.Store.CreateAlert(ctx, &deviceID, kind, message)
if err != nil {
e.Log.Error("alert create", "device", dev.Name, "kind", kind, "err", err)
return
}
if e.Notifier == nil || !e.Notifier.Configured() {
if err := e.Store.MarkAlertDelivered(ctx, alert.ID, "apprise is not configured"); err != nil {
e.Log.Error("alert record", "id", alert.ID, "err", err)
}
e.Log.Warn("alert not delivered: apprise is not configured",
"device", dev.Name, "kind", kind, "message", message)
return
}
title := "Internet Use Control — " + alertTitle(kind)
dctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
if err := e.Notifier.SendType(dctx, title, message, apprise.TypeWarning); err != nil {
if err := e.Store.MarkAlertDelivered(ctx, alert.ID, err.Error()); err != nil {
e.Log.Error("alert record", "id", alert.ID, "err", err)
}
e.Log.Error("alert delivery failed", "device", dev.Name, "kind", kind, "err", err)
return
}
if err := e.Store.MarkAlertDelivered(ctx, alert.ID, ""); err != nil {
e.Log.Error("alert record", "id", alert.ID, "err", err)
}
e.Log.Info("alert delivered", "device", dev.Name, "kind", kind, "alert_id", alert.ID)
}
// offlineTooLong reports whether a device has been unreachable past the
// threshold. A device that has never reported is never "offline too long": a
// freshly registered device is not an alert.
func (e *Engine) offlineTooLong(dev store.Device, now time.Time) bool {
if dev.Online || dev.LastSeen == nil {
return false
}
return now.Sub(*dev.LastSeen) > e.OfflineAfter
}
func (e *Engine) now() time.Time {
if e.Now == nil {
return time.Now().In(e.loc())
}
return e.Now()
}
func (e *Engine) loc() *time.Location {
if e.Location == nil {
return time.Local
}
return e.Location
}
// limitMessage is the one sentence Sam receives when a device is blocked.
func limitMessage(dev store.Device, eff store.EffectiveState, loc *time.Location) string {
reason := "its schedule"
if eff.Reason == "manual" {
reason = "a manual lock"
}
if eff.Until != nil {
return fmt.Sprintf("%s reached its limit and is blocked by %s (until %s).",
dev.Name, reason, eff.Until.In(loc).Format("15:04"))
}
return fmt.Sprintf("%s reached its limit and is blocked by %s.", dev.Name, reason)
}
// offlineMessage is the one sentence Sam receives when a device is unreachable.
func offlineMessage(dev store.Device, now time.Time) string {
if dev.LastSeen == nil {
return fmt.Sprintf("%s has been offline for a while.", dev.Name)
}
return fmt.Sprintf("%s has been offline for %s.", dev.Name, humanDuration(now.Sub(*dev.LastSeen)))
}
func alertTitle(kind string) string {
switch kind {
case store.AlertLimit:
return "limit hit"
case store.AlertOffline:
return "device offline"
case store.AlertTamper:
return "tamper alert"
default:
return "notification"
}
}
// humanDuration renders a duration compactly: "2h 10m", "45m", "under a minute".
func humanDuration(d time.Duration) string {
seconds := int(d.Seconds())
if seconds <= 0 {
return "0m"
}
if seconds < 60 {
return "under a minute"
}
m := seconds / 60
h := m / 60
m %= 60
if h == 0 {
return fmt.Sprintf("%dm", m)
}
return fmt.Sprintf("%dh %02dm", h, m)
}