Skip to content

feat(watcher): record screen and keyguard state in its own bucket - #380

Open
ThisIsLeoS wants to merge 3 commits into
ActivityWatch:masterfrom
ThisIsLeoS:feat/screen-state-bucket
Open

ThisIsLeoS wants to merge 3 commits into
ActivityWatch:masterfrom
ThisIsLeoS:feat/screen-state-bucket

Conversation

@ThisIsLeoS

@ThisIsLeoS ThisIsLeoS commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Adds a bucket that records whether the screen is interactive and whether the keyguard (lock screen) is shown, as spans of constant state, read from the same usage events as the session and unlock buckets.

Why

The app bucket records what is in the foreground, and that is not always someone using the phone: a screensaver started while charging is a foreground activity on a non-interactive screen, and an app is often resumed a moment before the screen turns on. With this bucket, a query can keep only app time while the screen was on and unlocked, and measure what #52 asked for: time on the lock screen and glances (screen on without unlocking).

screen = query_bucket("aw-watcher-android-screen");
unlocked = filter_keyvals(filter_keyvals(screen, "screen", ["interactive"]), "keyguard", ["hidden"]);
RETURN = filter_period_intersect(query_bucket("aw-watcher-android"), unlocked);

Format

Bucket aw-watcher-android-screen, type os.screen.state. Each event is a span with

{"screen": "interactive" | "non-interactive", "keyguard": "shown" | "hidden"}
  • The values are named after Android's events (SCREEN_INTERACTIVE/SCREEN_NON_INTERACTIVE, KEYGUARD_SHOWN/KEYGUARD_HIDDEN). Non-interactive is not "display off" (an always-on display is lit while non-interactive), and an app shown over the lock screen (camera, calls) leaves the keyguard shown.
  • The two values are kept apart because the keyguard also changes while the screen is off: it is shown a few seconds after screen-off, and a fingerprint can hide it before the screen turns on. A new span starts whenever either changes.
  • No event while either value is unknown: before the first events in Android's history, and from a shutdown until the events recorded at the next startup.
  • The last span runs until the time of the read and is extended by the next one; spans never overlap.

How it works

  • A pure function (parseScreenStateSpans) turns the events into spans; ScreenStateWatcher reads UsageStatsManager.queryEvents, resumes from the bucket's last event and writes the spans with the existing insertEvent. It runs on every session ingest run through one line in SessionEventWatcher, inside its lock; a failure is logged and does not affect the session and unlock ingest.
  • Resuming re-reads from the start of the last stored span and keeps its stored start and data, so the heartbeat merge extends that event, as the session bucket does.
  • Reboots: Android records a shutdown before every startup, also after a crash (UserUsageStatsService.init dates it at the last save). If the first shutdown or startup after the stored span's end is a startup, its shutdown predates the read, so the stored state is dropped and the state starts unknown.
  • Events Android adds to its history after a read but dates before it are placed at the end of the stored span, since the merge can only lengthen it.
  • Only on Android 10 and later (no DEVICE_SHUTDOWN before API 29), and only with usage access. No new native functions: it uses createBucket, getEvents and heartbeat.

Known limits are listed in the KDoc of ScreenStateWatcher: a gap between reads longer than Android's history, clock corrections of more than a couple of seconds, and two rarer cases around late events and interrupted writes.

Testing

  • Unit tests for the pure function (unlocks, glances, lock delay, fingerprint, reboots, crashes, clock corrections, late events, and a simulation of successive reads with the server's merge), for the event data and for the resume point.
  • Ran the function over about three weeks of usage-event history from a Samsung Galaxy S25 (One UI 8.5, Android 16): daily screen-on and unlocked totals matched an independent computation from the same events to the second, and reading in steps (every minute, ~16 minutes, hour) gave exactly the same spans as a single read, with no overlaps.
  • Ran it for four days on the same phone: it backfilled about ten days of history, no span overlaps another or crosses any of the 15 restarts in that period, and daily screen-on and unlocked totals agree with an independent computation from dumpsys usagestats dumps within seconds.
  • ./gradlew :mobile:testStandardDebugUnitTest passes and assembleResearchDebug builds.

Closes #52.

Turn SCREEN_INTERACTIVE/SCREEN_NON_INTERACTIVE, KEYGUARD_SHOWN/KEYGUARD_HIDDEN
and DEVICE_SHUTDOWN/DEVICE_STARTUP usage events into spans of constant state:
whether the screen is interactive and whether the keyguard is shown. This lets
later analysis tell the time the phone was actually usable from, for example,
a screensaver running in the foreground while the screen is non-interactive.

The two states are kept apart because the keyguard changes while the screen
is off (it is shown some seconds after screen-off, and a fingerprint can hide
it before the screen turns on). A span exists only while both are known: a
shutdown makes them unknown until the events recorded at the next startup.

The function is pure and resumes from the last stored span, keeping its start
and state, so that span is emitted again with only a later end and the
server's heartbeat merge extends it.

Refs: ActivityWatch#52
Add the bucket aw-watcher-android-screen, of type os.screen.state: one event
per span of constant state, with data

    {"screen": "interactive" | "non-interactive", "keyguard": "shown" | "hidden"}

The spans are read from the usage events on every session ingest run, resumed
from the bucket's last event, and the span still open is extended by the next
run. A failure is logged and does not stop the session and unlock ingest.

Only on Android 10 and later: earlier versions record no shutdown, and the
span open when the phone is turned off would run on until it is turned on.

Refs: ActivityWatch#52
@ThisIsLeoS
ThisIsLeoS marked this pull request as ready for review October 23, 2026 19:14
@greptile-apps

greptile-apps Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] The change adds screen-state records to one Android usage-tracking workflow.

The PR appears safe to merge, with a non-blocking improvement for null history responses.

Findings

  1. P2 Failed reads extend old state ▶

Summary

Adds aw-watcher-android-screen with separate screen and keyguard states.

  • Android records screen and lock state as time spans.

One non-blocking improvement remains: skip null history responses rather than extending the stored state.

ThisIsLeoS explicitly acknowledges limits around expired history, large clock corrections, repeated late events, and interrupted startup writes.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Session watcher acquires shared lock] --> B[Check Android version and usage access]
  B --> C[Read last screen span]
  C --> D[Query usage events from resume point]
  D --> E[Parse screen and keyguard spans]
  E --> F[Write spans through insertEvent]
  F --> G[Continue session and unlock writes]
Loading

Reviews (1) · Last reviewed commit: "feat(watcher): record screen and keyguar..." · Reviewed by Greptile

Before the user first unlocks the device after a boot, queryEvents returns
null (as documented from Android 11) or an empty result, and it returns an
empty result when usage access is revoked during the run. An empty read
would extend the last span to now, so skip the run before the first unlock,
fail it on null, and check usage access again after reading.
@TimeToBuildBob

Copy link
Copy Markdown
Contributor

CI diagnosis for head 88192219b89c: the only failing check is Test E2E (29). Unit tests, R8 smoke and the Rust build passed.

The retained logcat locates the timeout at DashboardThemePaintTest.kt:190, waiting for window.__awAndroidThemeState() to become pending. Chromium repeatedly reports that this function is undefined on /early, although the preceding test step attempted navigation back to /. That points to the inherited WebView test/navigation path, not a screen-state assertion; this PR does not modify that test or WebUIFragment. It is not enough evidence to declare the exact cause or approve the feature.

I attempted a base-side device replay, but the cached APK stopped earlier on a missing JNI setHostnameOverride implementation, so that attempt does not reproduce this timeout. I also cannot rerun this Actions job (GitHub requires repository admin rights). Recommendation: retain the CI gate and have a maintainer rerun the failed job before requesting changes to the screen-state code. If it repeats, investigate the inherited theme-test navigation with matching freshly built app/native artifacts. No watcher-code carry or merge from me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Screen interactive watcher

2 participants