Skip to content

feat(android): buzz the wrist for any app that notifies you, not just the curated nine - #2707

Open
suprfire wants to merge 9 commits into
ryanbr:mainfrom
suprfire:feat/android-discover-any-notifier-app
Open

suprfire wants to merge 9 commits into
ryanbr:mainfrom
suprfire:feat/android-discover-any-notifier-app

Conversation

@suprfire

@suprfire suprfire commented Oct 4, 2026 •

Copy link
Copy Markdown

What

Replaces the curated 9-app notification catalog with live on-device discovery. The Notifications screen gains an App Notifications section (a Choose Apps button opening its own page) that lists every user-installed app that can post notifications — each with its own enable switch, buzz pattern, and test button. Wrist alerts inherit the existing quiet-hours and only-when-worn rules unchanged, and gain a buzz-specificity gate: progress-bar rows and persistent non-dismissable rows never buzz.

Why

The curated catalog covers nine apps. Everything else — BeReal, Slack, Ring, whatever — was reachable only through the "All other apps" catch-all (#168), which buzzes for everything or nothing. Users asked to pick apps one by one.

How

  • NotifierAppDiscovery merges two sources: installed apps declaring POST_NOTIFICATIONS (via a MAIN/LAUNCHER-shaped <queries> entry — no QUERY_ALL_PACKAGES, which is Play-restricted and NOOP ships sideloaded) plus every package the listener has seen post (the Notification Access grant carries their visibility, covering pre-API-33 targets that never declare the permission).
  • System packages are filtered by path, on purpose: the enumeration path drops FLAG_SYSTEM (OEM agents and bundled services that never notify shouldn't flood the list), the discovered path does not — a preinstalled app that has actually posted a notification (Gmail, Samsung Messages, a carrier app) is one the user demonstrably uses, and hiding it would leave a real notifier unpickable. Posting is the evidence; the flag is not.
  • Package names only; notification content is never read, nothing leaves the device.
  • Per-app prefs reuse the existing keys (app.<pkg>.enabled / app.<pkg>.pattern), so the listener gates discovered apps exactly like curated ones — no new preference surface.
  • Buzz specificity (WristAlertFilters): the per-app mirror path skips progress notifications (download/upload bars, timer countdowns) and persistent non-dismissable rows (FLAG_NO_CLEAR) — app state readouts, not alerts. The Clock alarm/timer path additionally skips progress rows: a running timer no longer buzzes at start, while a genuine alarm ring is a fresh post without progress and keeps its triple buzz (ongoing stays allowed on that path — the whole point of Explore haptic/vibration feedback for incoming calls #1115 — so the persistent gate is deliberately not applied there). Progress is detected from extras values (progress/progressMax above zero, or progressIndeterminate=true) plus the android.widget.ProgressBar template — never key presence (see Follow-ups).
  • Upgrade path for category-card users: the cards were UI grouping over the same per-app keys — their toggles wrote app.<pkg>.enabled, so an opted-in user's enabled set carries over untouched and appears pinned in the picker (no category key space exists to migrate; verified on the base tree: card onToggle → setAppEnabled only). What the cards did carry implicitly was their per-category default pattern (Messaging = Single, Email/Calendar = Double, Meetings = Triple), which was never persisted for apps whose pattern was untouched. The picker's package-keyed read defaults to Double for ALL apps — so a one-time startup seed writes each legacy default as an explicit app.<pkg>.pattern ONLY where the key is absent, never overwriting an explicit choice, flag-guarded to run once. A Messaging user's Single buzz survives the upgrade; pinned by NotifPatternSeedTest.

UI

  • Notifications page: Alerts Email / Messaging / Meetings / Calendar & Reminders cards removed (redundant with the picker); App Notifications section sits right after Calls.
  • App Notifications page: the "All other apps" catch-all moves to the top in its own section, with the mutual-exclusion explanation (catch-all on ⇒ individual switches inert); opted-in apps pinned above the search field, visible from the moment the page opens (labels/icons resolve in a fast first off-thread sweep, ahead of the full scan); "Scanning Installed Apps…" placeholder while the PackageManager walk runs; real launcher icons; search; Scan again.
  • Header pill counts every persisted opt-in, re-read on resume — it tells the truth about what will buzz.
  • Live Notification Access indicator: the grant is per-app, so a silent dead link (access off, toggles on) now reads as what it is.

Scope

Android only. Zero iOS/macOS files touched — the Apple tree is untouched (git diff --name-only <base>..HEAD | grep -v '^android/' is empty).

Tests & gates

  • NotifierAppDiscoveryTest: 10 tests (filter rule, discovery store, system-package exclusion on the enumeration path, and a regression test that a preinstalled poster like Gmail stays pickable).
  • NotifPatternSeedTest: 5 tests (carried-over Messaging opt-in keeps Single; explicit choices never overwritten; disabled apps get no seeded key; seed runs once and never re-applies; Triple/Double defaults pinned verbatim to the removed category cards).
  • WristAlertFiltersTest: 8 tests on the pure framework-snapshot policy (plain alert is neither progress nor persistent; determinate bar, indeterminate spinner, and ProgressStyle template are filtered; NO_CLEAR is persistent-only; the combined timer row) — including the regression test that the framework's default extras values do NOT filter a plain notification.
  • Full suite: 6,646 tests, 0 failures, 10 skipped.
  • Tools/i18n_audit.py --ci HEAD: no new hardcoded literals, no new Android locale gaps; all new copy extracted across the 9 gated locales (de/es/fr/it/pl/pt-PT/ru/zh).
  • Tools/doc_comment_lint.py: clean.

Refs #168, #1115, #921

  • Discovery module + manifest queries (no QUERY_ALL_PACKAGES)
  • Listener records posting packages; per-app prefs unchanged
  • App Notifications page (pinned picks, scanning placeholder, real icons)
  • Redundant category cards removed; header pill counts discovered picks
  • Category-card upgrade path: enabled prefs carry over (same keys); pattern defaults seeded once at startup
  • Notification Access indicator
  • Buzz specificity: progress and persistent rows never buzz; alarm rings keep their triple buzz (value-based extras gate, regression-tested)
  • i18n: all new strings localized in the 9 gated locales
  • Unit tests + CI gates green
  • Screenshots (below, hosted on a fork assets ref — not committed to the tree)

Follow-ups since opening

Review pass (#2707 comment 6142632300), item by item:

  • Preinstalled posters stay pickable. The first revision filtered FLAG_SYSTEM on both discovery paths, which hid OEM-preinstalled apps that actually notify (Gmail, Samsung Messages). The flag filter now applies only to the enumeration path; anything the listener has seen post is offered regardless of the flag. Regression test included.
  • Dropped the "Buzzes your wrist" caption on toggled-on rows — the switch alone carries the state.
  • Hand-written CHANGELOG section dropped. Main is on 12.1.0 (beta cut 555/436) and release sections are generated from docs/releases/vX.Y.Z.md by Tools/appchangelog-gen.py, so the hand-written ## 12.0.1: heading goes; the prose stays here for whoever writes the notes for the version that carries this.
  • Screenshots out of the tree. docs/screenshots/Screenshot1-3.png are removed from the diff — the directory exists nowhere on main, nothing references them, and a picker screenshot archives whatever was installed on the phone it was taken on. They stay in this body hosted on the fork's assets/pr-2707-screenshots ref (metadata-stripped), not in the merged tree.
  • Acknowledged buzz write extracted to its own change. buzz → buzzAcknowledged alters what goes over the radio on every mirrored notification, in a PR whose subject is a settings screen, and its justification is the Sweep the Android French for the #884 error classes, with agreement (#884) #921 hypothesis, not a measurement that a without-response write was dropped. Confirmed writes cost a GATT round trip on a strap family with a drain history (android: advance the #836 watermark after the post-backfill re-score #1120/WHOOP 4.0: offload auto-continue (#364/#451) spins empty-offload storms — guard 2a ignores rows #1144/fix(analytics): stop the post-offload re-score flickering daily history to empty (#1196) #1197/fix(ble): gate empty-offload auto-continue on the sensor-record verdict, not a re-read counter (#1146) #1198). The picker lands with the bare write; the acknowledged write will arrive separately with drop evidence attached, so a later drain move has one variable to look at.
  • Pattern-default seed for upgrades (the review question). See How → upgrade path: enabled state carries over on the same keys; the per-category pattern defaults the cards applied implicitly are seeded once at startup where absent. Without it, upgrading silently turns a Messaging user's Single buzz into Double — the check CI cannot see.
  • Buzz specificity for progress and persistent rows (measured on-device). Testing the picker showed mirrored apps buzz for download/upload bars, running timers, and non-dismissable rows — state readouts, not alerts. The first filter attempt gated on extras.containsKey("android.progress"), which is a trap: the framework seeds android.progress=0, android.progressMax=0 and android.progressIndeterminate=false into the extras of ORDINARY text alerts (device dumps show all three keys on plain notifications), so the gate silenced every buzz. The shipped gate reads VALUES only — indeterminate flag set, progress/progressMax above zero, or the ProgressBar template — with a pinned regression test asserting plain framework defaults are not filtered. On the alarm path this silences timer starts; alarm rings (fresh posts, no progress) keep their triple buzz.
  • Acknowledged-write evidence: measured, and it points elsewhere. 2026-10-13 strap log (5/MG, 102 haptic writes through a build with acknowledged mirrors): every haptic frame was answered in-band (Command response: RUN_HAPTIC_PATTERN_MAVERICK → PENDING(2)/FAILURE(0)), including bare writes. No dropped bare write was found, so the evidence bar the acknowledged-write follow-up must clear has not been met — that change stays unshipped. The real measured signal is strap-side pacing: FAILURE(0) answers appear when buzzes arrive faster than ~1s apart (a notification burst floods the pattern player). Pacing/coalescing of mirrored bursts is the candidate follow-up, not write mode.

Screenshots

1 — Notifications page: the App Notifications section sits right after Calls, with the full-width Choose Apps button. The delivery note shows the Notification Access grant live.

Notifications page

2 — App Notifications page, mid-scan: the opted-in app (BlueBubbles, pinned above search with its Triple pattern) is visible while the scan is still running — the "Scanning Installed Apps…" placeholder fills the list area below.

Mid-scan placeholder

3 — App Notifications page, scan complete: pinned pick on top, the discovered notifier apps below with their real launcher icons, each with its own switch.

Scan complete

alex and others added 4 commits October 4, 2026 19:15
… the curated nine

Android-only. Replaces the curated 9-app notification catalog with live on-device
discovery: an App Notifications page listing every user-installed app that can post
notifications, each with its own enable switch, buzz pattern, and test button.

- NotifierAppDiscovery merges installed apps declaring POST_NOTIFICATIONS (via an
  intent-shaped <queries> entry - no QUERY_ALL_PACKAGES) with every package the
  listener has seen post; FLAG_SYSTEM filtered on both paths. Package names only,
  on-device, no notification content read.
- Per-app prefs reuse the existing keys, so discovered apps buzz exactly like curated
  ones and inherit the quiet-hours and only-when-worn rules unchanged.
- Notifications page: redundant category cards removed, App Notifications section after
  Calls, header pill counts every persisted opt-in.
- App Notifications page: catch-all at top with the mutual-exclusion explanation,
  opted-in apps pinned above search, scanning placeholder, real launcher icons.
- Delivery note shows the Notification Access grant live; mirror buzz written
  acknowledged (ryanbr#921 lesson).
- All new copy extracted across the 9 gated locales.

Refs ryanbr#168, ryanbr#1115, ryanbr#921
…kable - drop FLAG_SYSTEM on the discovered path only
… rows - the switch alone carries the state
@ryanbr

ryanbr commented Oct 13, 2026

Copy link
Copy Markdown
Owner

Approved the workflow runs, so this has now been through CI and all four checks are green.

The parts I expected to be delicate check out. The manifest addition really is the narrow query you describe, a MAIN/LAUNCHER shaped <intent> inside the existing <queries> block with no QUERY_ALL_PACKAGES. All 13 new strings are present in values and in all eight locale files, which is why the i18n gate passed rather than blocking. And NotifierAppDiscovery.record returns early on a package it already knows and writes with apply(), so the per-notification cost on the listener thread is negligible. The asymmetric system-package filtering is well argued: posting is evidence, the flag is not.

Three things to send back, plus one question.

1. The CHANGELOG section

CHANGELOG.md gains a hand-written ## 12.0.1: Pick any app that notifies you (Android) heading. Two problems with that. The version is not next: main is already on 12.1.0, whose test beta is cut at 555 / 436. And release sections are generated from docs/releases/vX.Y.Z.md by Tools/appchangelog-gen.py at release time, so a hand-written one will either be overwritten or collide. Please drop that hunk. The prose itself is good and will be useful when the notes for whatever version carries this get written.

2. Three unreferenced screenshots

docs/screenshots/Screenshot1.png, Screenshot2.png and Screenshot3.png are added, that directory does not exist on main, and nothing anywhere on the branch references them. They look like PR illustrations that got committed. Worth noting separately that a screenshot of a notifier-app picker shows whatever is installed on the phone it was taken on, which is not something to park in the repository permanently. Please take all three out.

3. The acknowledged buzz belongs in its own change

I went looking for this as an undeclared change and it is not one: it is in the body, in the checklist, and in Refs #921. So this is not a complaint about disclosure. It is that buzz to buzzAcknowledged alters what goes over the radio on every mirrored notification, in a PR whose subject is a settings screen, and the justification is a hypothesis about why #921 happened rather than a measurement that a without-response write was being dropped. Confirmed writes cost a GATT round trip, and this strap family has a drain history that turned out to be reconnect and traffic shaped (#1120, #1144, #1197, #1198). I would rather take the picker now and have the write change arrive on its own, with a log showing a dropped bare write, so that if drain moves afterwards there is one variable to look at instead of two.

The question

The Notifications page drops the Email, Messaging, Meetings and Calendar category cards on the grounds that the picker covers them. Per-app prefs reuse app.<pkg>.enabled and app.<pkg>.pattern, so a user who picked individual apps is carried over. But the category toggles are a different key space. What happens to someone who had Messaging switched on, upgrades, and never opens the new picker? The catch-all surviving default-off does not cover them, since they had made an explicit choice. If their buzzes stop silently that is the one regression no check in CI can see, and if there is a migration I have missed then it is worth a line in the body.

The feature itself is one people have asked for and the discovery approach is the right one. These are all things to settle before it lands, not objections to the shape of it.

NoopMod added 4 commits October 13, 2026 18:29
Release sections are generated from docs/releases/vX.Y.Z.md by Tools/appchangelog-gen.py at release time, so a hand-written heading collides; and 12.0.1 is not next (main is 12.1.0, test beta cut at 555/436). The prose lives on in the PR description for whoever writes notes for the version that carries this.
The picker screenshots were PR illustrations, not repo assets: docs/screenshots does not exist on main, nothing on the branch references them, and a notifier-app picker screenshot permanently archives whatever was installed on the phone it was taken on. Stripped originals move to a fork assets branch for the PR body.
buzz -> buzzAcknowledged alters what goes over the radio on every mirrored notification, in a PR whose subject is a settings screen, and the justification is the ryanbr#921 hypothesis, not a measurement that a without-response write was dropped. Confirmed writes cost a GATT round trip on a strap family with a drain history (ryanbr#1120/ryanbr#1144/ryanbr#1197/ryanbr#1198). The picker lands with the bare write; the acknowledged write ships separately with drop evidence attached, so a later drain move has one variable to look at.
…the buzz rhythm

The category cards applied a per-category default pattern (Messaging = Single, Email/Calendar = Double, Meetings = Triple) to opted-in apps that never picked one; nothing was persisted for them. The picker's package-keyed read defaults to Double for ALL apps, so upgrading without a seed silently changes a Messaging user's Single buzz into Double - the silent class of regression the review asked about. Enabled state needed no migration: the cards wrote the same app.<pkg>.enabled keys the picker reads (cards' onToggle -> setAppEnabled only, verified on the base tree). The seed runs once at startup, writes ONLY where the pattern key is absent, never overwrites an explicit choice, and is pinned by NotifPatternSeedTest.
@suprfire

Copy link
Copy Markdown
Author

Thanks for the close read. All four items addressed; pushed as 95387b25..4cf01a03, one commit each so they stay separable.

1 — CHANGELOG hunk: dropped. 9145361a removes the hand-written section entirely. You're right on both counts — 12.0.1 is not next (main is 12.1.0, beta cut at 555/436), and release sections come from docs/releases/vX.Y.Z.md via Tools/appchangelog-gen.py. The prose lives on in the PR body for whoever writes the notes for the version that carries this.

2 — Screenshots: out of the tree. 9a782cd5 removes all three PNGs. Your point about what a picker screenshot archives is the one I'd internalized: the images are metadata-stripped and stay in the PR body, hosted on a dedicated fork ref (assets/pr-2707-screenshots) so they never enter the merged tree; the body's embeds were repointed and HEAD-checked 200.

3 — Acknowledged buzz: extracted. cf20bded returns the listener to plain buzz and deletes buzzAcknowledged; the picker lands with the bare write, radio-unchanged. The acknowledged write will arrive on its own branch with a log showing an actual dropped bare write attached — agreed that a drain move needs one variable, not two, and the #921 rationale is hypothesis, not measurement.

4 — The question (category-card users). Verified on the base tree: the cards were UI grouping over the same key space — CategoryCard.onToggle writes app.<pkg>.enabled per app (id == package), so a user with Messaging on carries over untouched: their WhatsApp/Messages stay opted-in, get pinned in the picker, and inherit every gate. There is no category key space, so nothing to migrate for the enabled state, and buzzes do not stop.

While tracing this I found the regression that IS silent, in the direction you pointed: the pattern default. Base code fell back to app.category.defaultPattern (Messaging = Single, Email/Calendar = Double, Meetings = Triple) when no pattern was persisted; the picker's appLoops/appPatternFor fall back to Double for everyone. A Messaging user who opted in and never touched the pattern silently gets a Double buzz after upgrading. Fix in 4cf01a03: a one-time startup seed (seedLegacyCategoryPatternDefaults, called from NoopApplication.onCreate beside the #1008 migration) writes each app's old category default as an explicit app.<pkg>.pattern ONLY where the key is absent — never overwrites an explicit choice, flag-guarded to run once. Pinned by NotifPatternSeedTest (5 cases: carryover keeps Single, explicit choices survive, disabled apps get no seeded key, seed never re-applies, Triple/Double defaults match the removed cards verbatim). The body now carries the migration line you asked for.

Gates on the new head: compile green; NotifPatternSeedTest 5/5 and NotifierAppDiscoveryTest 10/10; i18n audit — Android clean, no new literals/gaps; doc lint clean; git diff --name-only <base>..HEAD | grep -v '^android/' empty (the CHANGELOG restored to baseline, so the tree delta is Android-only). Full suite: 9 failures, all in WhoopDatabaseUpgradeTest (2/2), GlowRingFlatArcTest (3/3), StrapLogArchiveTest (4/10) — confirmed on a clean worktree at 95387b25 with identical counts, i.e. the known Windows-host set, not from this push. CI (Linux) should run green on approval.

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.

2 participants