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

227 lines
6.6 KiB
Go

// Alerts (AGENTS.md §4 FR-13, §7 retention, §11 Phase 9).
//
// An alert is a message the Control Service decided Sam should see: a device
// hit its limit (a blocking window turned on), or a device has been offline too
// long. The engine (internal/alert) raises them and delivers them through
// Apprise; this file only records what happened.
//
// `alerts` is append-only except for the delivery outcome, so an operator can
// always tell what fired and whether it reached Sam. `alert_state` is the small
// per-device dedup memory that turns a transition into exactly one alert.
package store
import (
"context"
"database/sql"
"fmt"
"time"
)
// Alert kinds. 'limit' is a device that became blocked, 'offline' is a device
// 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"
)
// Alert is one recorded alert.
type Alert struct {
ID int64
DeviceID *int64
DeviceName string
Kind string
Message string
CreatedAt time.Time
Delivered bool
DeliveredAt *time.Time
Error string
}
// AlertState is the last observed condition for one device. It exists only to
// suppress duplicate alerts while a condition stays true.
type AlertState struct {
Locked bool
Offline bool
}
// CreateAlert records one alert and appends it to the audit log in the same
// 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, AlertTamper, AlertNotify:
default:
return nil, fmt.Errorf("create alert: unknown kind %q", kind)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("create alert: %w", err)
}
defer func() { _ = tx.Rollback() }()
var idArg any
if deviceID != nil {
idArg = *deviceID
}
res, err := tx.ExecContext(ctx,
`INSERT INTO alerts (device_id, kind, message) VALUES (?, ?, ?)`,
idArg, kind, message)
if err != nil {
return nil, fmt.Errorf("create alert: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return nil, fmt.Errorf("create alert id: %w", err)
}
if err := insertAudit(ctx, tx, "alert", "alert."+kind, deviceID, message); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit alert: %w", err)
}
return s.GetAlert(ctx, id)
}
// MarkAlertDelivered records the delivery outcome. An empty errMsg means Apprise
// accepted the notification.
func (s *Store) MarkAlertDelivered(ctx context.Context, id int64, errMsg string) error {
delivered := 0
if errMsg == "" {
delivered = 1
}
if _, err := s.db.ExecContext(ctx, `
UPDATE alerts SET delivered = ?, delivered_at = datetime('now'), error = ?
WHERE id = ?`, delivered, nullString(errMsg), id); err != nil {
return fmt.Errorf("mark alert delivered: %w", err)
}
return nil
}
// GetAlert returns one alert, or ErrNotFound.
func (s *Store) GetAlert(ctx context.Context, id int64) (*Alert, error) {
row := s.db.QueryRowContext(ctx, `
SELECT a.id, a.device_id, COALESCE(d.name, ''), a.kind, a.message,
a.created_at, a.delivered, a.delivered_at, COALESCE(a.error, '')
FROM alerts a
LEFT JOIN devices d ON d.id = a.device_id
WHERE a.id = ?`, id)
a, err := scanAlert(row)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get alert: %w", err)
}
return a, nil
}
// ListAlerts returns the newest alerts first, for the Alerts screen and for
// verification. A non-positive or oversized limit is clamped.
func (s *Store) ListAlerts(ctx context.Context, limit int) ([]Alert, error) {
if limit <= 0 || limit > 500 {
limit = 50
}
rows, err := s.db.QueryContext(ctx, `
SELECT a.id, a.device_id, COALESCE(d.name, ''), a.kind, a.message,
a.created_at, a.delivered, a.delivered_at, COALESCE(a.error, '')
FROM alerts a
LEFT JOIN devices d ON d.id = a.device_id
ORDER BY a.id DESC
LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("list alerts: %w", err)
}
defer rows.Close()
out := make([]Alert, 0, limit)
for rows.Next() {
a, err := scanAlert(rows)
if err != nil {
return nil, fmt.Errorf("scan alert: %w", err)
}
out = append(out, *a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list alerts: %w", err)
}
return out, nil
}
// AlertStates returns the dedup state for every device that has one. A device
// with no row is a device the engine has not seen yet.
func (s *Store) AlertStates(ctx context.Context) (map[int64]AlertState, error) {
rows, err := s.db.QueryContext(ctx, `SELECT device_id, locked, offline FROM alert_state`)
if err != nil {
return nil, fmt.Errorf("alert states: %w", err)
}
defer rows.Close()
out := make(map[int64]AlertState)
for rows.Next() {
var (
id int64
locked, offln int
)
if err := rows.Scan(&id, &locked, &offln); err != nil {
return nil, fmt.Errorf("scan alert state: %w", err)
}
out[id] = AlertState{Locked: locked != 0, Offline: offln != 0}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("alert states: %w", err)
}
return out, nil
}
// SetAlertState records the last observed condition for one device.
func (s *Store) SetAlertState(ctx context.Context, deviceID int64, locked, offline bool) error {
if _, err := s.db.ExecContext(ctx, `
INSERT INTO alert_state (device_id, locked, offline, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(device_id) DO UPDATE SET
locked = excluded.locked,
offline = excluded.offline,
updated_at = datetime('now')`,
deviceID, boolToInt(locked), boolToInt(offline)); err != nil {
return fmt.Errorf("set alert state: %w", err)
}
return nil
}
func scanAlert(row interface {
Scan(dest ...any) error
}) (*Alert, error) {
var (
a Alert
deviceID sql.NullInt64
created sql.NullString
delivered int
deliveredAt sql.NullString
)
if err := row.Scan(&a.ID, &deviceID, &a.DeviceName, &a.Kind, &a.Message,
&created, &delivered, &deliveredAt, &a.Error); err != nil {
return nil, err
}
if deviceID.Valid {
id := deviceID.Int64
a.DeviceID = &id
}
a.CreatedAt = parseTime(created.String)
a.Delivered = delivered != 0
a.DeliveredAt = parseTimePtr(deliveredAt)
return &a, nil
}
// nullString maps an empty message to SQL NULL, so an undelivered-without-error
// row is distinguishable from a delivered one.
func nullString(s string) any {
if s == "" {
return nil
}
return s
}