Imported from rohan-js/guardpulse-tv (
tv/AGENTS.md). Install upstream withnpx skills add rohan-js/guardpulse-tv --skill tv. Copyright stays with the author.
AGENTS.md — GuardPulse TV module (tv/, plus shared/)
Operating guide for any AI agent or developer working on the Android TV app of
GuardPulse. Read this before changing anything in tv/ or shared/. For full
project history and the superseded handoffs, read ../PROJECT_CONTEXT.md
(2026-09-03 section is authoritative).
What this app is
com.guardpulse.parentcontrol.tv (module tv/, namespace
com.guardpulse.parentcontrol.tv) — a parental-control enforcement agent that
runs permanently on an Android TV (Xiaomi MiTV4I, Android 9, SDK 28, NOT device
owner → "fallback mode"). It receives control data from a parent phone app via
Firebase RTDB (control/v2 desired state), enforces it locally through an
accessibility service that launches a PIN-gated lock wall over blocked apps,
acks applied revisions, and uploads telemetry (state, usage, activity,
heartbeat, security probes). Module shared/ holds the protocol contracts
(ControlProtocol, FirebasePaths, PolicyConstants, PinHasher, DateKeys,
PackageKeys) used by both this app and the parent phone app — changes there
affect BOTH clients and possibly the deployed Firebase rules.
Current shipped state
- TV: 0.2.9 (versionCode 11) — the 2026-09-09 audit fix set (0.2.8) plus
the 2026-09-11 resource-optimization pass (see
../PROJECT_CONTEXT.mdsections for both). Parent: 0.3.2 (versionCode 5). - TV 0.2.8 was INSTALLED on the family TV on 2026-09-10, and 0.2.9 on
2026-09-12, via assembleRelease → zipalign → debug-keystore apksigner →
adb install -r(data/pairing preserved; device id and LAN address live in the local-only handoff notes, never in this repo). - Firebase project
<your-firebase-project>(see firebase.local.properties / .firebaserc locally); the hardened rules (sessionLimitMinutesdeclarations, legacypolicy/*$otherguards, tamperEvents TV-delete restriction) were DEPLOYED on 2026-09-10 and verified live. The web dashboard (web/, same repo) is live on the project's Firebase Hosting URL. - TV unit tests green (
./gradlew :tv:testDebugUnitTest). - Full handoff with release-by-release details:
../PROJECT_CONTEXT.md, section "Authoritative Continuation Handoff (2026-09-03)".
Hard rules (violating these has caused real incidents)
- Never bump into a stale signing identity. The installed TV app and the
parent phone app are BOTH signed with the ANDROID DEBUG KEYSTORE
(
tv-install-backups/debug.keystore, passwords on file locally — never in this repo), not the repo release keystore. BuildassembleRelease, then zipalign, then sign with the debug keystore viaapksigner, thenadb install -r. A release-signed APK cannot update the device. - Every Firebase write must match the deployed rules. The rules use
"$other": {".validate": false}everywhere. Writing ANY field not declared for that node (e.g.startedAtinstead ofclaimedAtin command claims — this silently killed the whole remote-command channel once) fails the whole write with "Permission denied". When adding a field: rules JSON + deploy + both clients in the same change-set. Validate the JSON (python -c "import json;json.load(open('firebase/database.rules.json'))") and deploy:firebase deploy --only database --project <your-firebase-project>. - Never clear app data or uninstall on the TV unless the user explicitly
accepts losing pairing, PIN, and accessibility grants. Update with
adb install -ronly. - Stale policy caches were a shipped bug once (0.2.3).
LocalPolicyStoreuses a process-wide companion cache with synchronous write-through — if you add another write path tolocal_policyprefs, keep the cache coherent in the SAME call stack (the prefs change-listener is async and was the root cause). - Anything that writes
state/apps/<pkg>must write the FULL field set — partial leaf writes create nodes the rules reject. This is whyuploadForegroundUsageis gated to inventory packages. - Ack semantics:
sync/appliedrequires revisionId == sync/desired AND == control/v2 AND sessionId == sync/runtime/sessionId. Guard every ack path. - Multi-path atomicity cuts both ways: one bad child denies the whole
updateChildren. When adding channels, follow the existingrecordSyncError(channel)/markChannelSynced(channel)pattern. - Do not run disruptive ADB actions (reboot, force-stop) while the TV is in
use without asking.
force-stopputs the app in STOPPED state — broadcasts (including BOOT_COMPLETED) stop until the app is started once.
Architecture map (who does what)
tv/sync/TvSyncService.kt(~1700 LOC, the core): foreground service; owns Firebase listeners, the control-apply pipeline (applyV2Control→effectivePolicies()→saveEffectivePolicies()→applyPoliciesAndUpload), state/usage/heartbeat/inventory/activity uploads, command handling, remote unlock application, security probes (5-min cache), SystemTimeGuard offset refresh. Retry/backoff lives inTvSyncEngine(serialized Main.immediate event loop; per-event runCatching; exponential backoff 5s→10min).tv/sync/EffectivePolicies.kt: PURE merge (unit-tested) — base policies, overlaid by active-mode entries, defaults putIfAbsent. If you touch policy semantics, change it here and keep TvSyncService thin.tv/policy/LocalPolicyStore.kt: SharedPreferenceslocal_policy+ the process-wide policy cache (see hard rule 4). Day keys are UTC on SystemTimeGuard time.tv/system/ScreenState.kt+ the a11y service's screen receiver: while the display is OFF the a11y poll stops, the media probe is released, and the sync tick skips the per-app reconcile + activity/usage telemetry (heartbeat keeps writing — parent freshness depends on it). All enforcement deadlines are timestamp-based, so pausing polls cannot extend or shorten them; if a ROM never broadcasts screen events this degrades to the old always-polling behavior. The poll also relaxes to 5 s after 60 s of event silence.tv/fallback/AppMonitorAccessibilityService.kt: the enforcer. Evaluates every window event →SettingsSectionDetector(section locks) →FallbackProtection.shouldLock→LockLaunchGuard(1.5s dedupe) → opensLockActivity. Also feedsTvActivityTracker(media titles) and live usage sessions. Poll safety-net every 1s (5s when idle, stopped when screen off); 300ms settle recheck after TYPE_WINDOW_STATE_CHANGED.rootInActiveWindowis fetched lazily (only Settings or a due media node-walk needs it);com.android.systemuioverlay events never finalize the live usage session. Since 0.2.8: Settings-section unlock clears only on a real window transition leaving Settings (never on a detector miss — a miss used to re-lock mid-visit and hand out a whole-Settings one-visit unlock via the fallback wall);com.android.systemuievents never count as "left the app" for unlock clearing; own-package not-locked decisions do not reset the LockLaunchGuard dedupe key (the covered app's content events used to relaunch the wall and wipe the PIN being typed).tv/fallback/LockActivity.kt: PIN wall. Binds remote unlock listeners on EVERY bind (onCreate + onNewIntent); same-target onNewIntent is a no-op rebind (keeps PIN entry + pending request); auto-dismiss poll (750ms) honors unlocks; BACK swallowed; singleTask. DEL backspaces one digit, CLEAR wipes.tv/fallback/FallbackStateStore.kt: unlock grants (temp/app-visit/ per-section), PIN record (Keystore via SecureValueStore, fail-closed on corruption), admin-disable gate, safe mode (server-time hardened).tv/activity/: media-title capture.MediaAccessibilityParser(pure, the title-selection heuristics),TvActivityTracker(snapshot cache + SQLite history v4),MediaTitlePolicy(evidence-based walk gate),MediaBrowserProbe(binds apps' MediaBrowserServices),MediaSessionListenerService+MediaSessionHub(system sessions — INERT on the current TV, see blockers),PlaybackAudioMonitor. Upload channel lives in TvSyncService (uploadActivityTelemetry). Since 0.2.9 the tracker persists the current snapshot on MEANINGFUL change only (position-only advances ride a 15 s heartbeat, because the parent extrapolates the playhead client-side) — the old every-eventactivity_stateJSON write was the top steady-state cost.tv/system/SystemTimeGuard.kt: monotonic clock floor + server offset. ALL new deadline logic must use it, notSystem.currentTimeMillis().tv/pairing/PairingManager.kt: pairing code/secret (constant-time compare, 20-strike rotation), cached parent UID.tv/network/NetworkFilterController.kt: intentional no-op stub (VPN feature not implemented on TV) — keep the "disabled" status write-once semantics.shared/: contracts shared with the parent app.PolicyConstantssection lists drive BOTH the TV detector and the parent phone's card rendering — adding a virtual section there makes it appear on the phone automatically after the next inventory rescan.
Testing
./gradlew :tv:testDebugUnitTest— 45 tests. Pure-JVM only (no Robolectric); keep new logic in pure objects (see EffectivePolicies, MediaTitlePolicy, MediaAccessibilityParser, TvStateDiff, ApprovedUnlockPolicy) and test there.- Rules tests (
firebase/database.rules.test.js) need the Firebase emulator + JDK 21 (NOT available on this machine) — validate rules JSON syntax with Python and pattern-copy existing clauses instead. - Before shipping: run tests, assembleRelease, debug-sign,
adb install -r, then verify against LIVE Firebase (firebase database:getwithMSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL="*"from Git Bash;database:updateneeds-f). Suggested live checks:sync/appliedmatchessync/desired,security/runtime/lastSyncErrornull,state/appslocks flip after a toggle,activity/currentupdates.
On-device debugging notes
- App label in accessibility dumps: "Device Support Service" (that's ours).
am start -n com.guardpulse.parentcontrol.tv/.MainActivityopens the PIN- gated setup screen (launcher entry exists since 0.2.7; monkey does NOT work).- If the lock wall is up and you need in: create + approve an unlockRequest in RTDB (status pending → approved with approvalType oneVisit + updatedAt) for the locked package; TvSyncService applies it even without LockActivity.
- Secure settings sometimes need re-injection after reboots:
enabled_accessibility_services+accessibility_enabled=1(restore commands in PROJECT_CONTEXT.md). uiautomator dumpoften returns "null root node" while our service is bound; try repeatedly after key events, dump to a RELATIVE path, and pull withadb pull data/local/tmp/x.xml.
Known limitations (do not "fix" without understanding)
- Notification access (media sessions) is ungrantable on this TV ROM —
MediaSessionListenerServicestays dormant; titles come from the screen-reader route. YouTube fullscreen has an empty a11y tree → no titles there; this is a platform limitation, not a bug. networkBlocked/vpnApplied/vpnActivestate fields are always false (the VPN feature doesn't exist on TV; schema kept for phone compatibility).lastValidV2Snapshotwas removed (write-only dead code) — don't re-add snapshot persistence without a consumer.