feat(notifications): the in-app inbox, and per-channel preferences (engagement Phase 8) #42

Merged
whitlocktech merged 3 commits from feature/engagement-inapp-android into edge 2026-08-31 14:37:36 +00:00
Member

The app's half of the in-app channel. Phase 7 shipped four inbox routes with no consumer on either platform; this is the Android one, plus the per-channel preferences Phase 3 added and the shipped screen could not express.

Design of record: docs/website/ENGAGEMENT.md Phase 8 (docs#190). Four decisions were settled by the org lead before any code.

What changed

  • The drawer's "Notifications" is the INBOX now, with the preferences one tap away behind its gear — the arrangement Phase 7 shipped on the web (the bare path is the content, …/settings the preferences), and what a person means when they tap the word.
  • The settings screen moved onto /notifications/channels. Controls are rendered from the wire: a control per channel in that item's channels, its shape from that channel's modes — a switch for two, chips for three, so email's digest reaches the app and a fourth channel would too, with no release. A trigger-only id shows no push control rather than a dead switch. Each change is one sparse PUT of one pair. The old endpoint is the push projection of the new table server-side, so the shipped APK kept working the whole time.
  • A tapped tickle routes on its ref, not its stream. An engagement rule's tickle carries the TRIGGER id as stream (§7.2's one namespace) and PushStreams knows only the eight push streams, so team.forum.post would have landed on Home. Routes.forTickle sends anything whose ref starts with notification: to the inbox and leaves every other tickle where it has always gone. The ref is never decoded past that prefix and never rendered — it is a hint that a row exists, and the contract stays wake-and-pull, exactly as pushChannel.js says.
  • Offline snapshot, and still no Room. PLAN.md §7's "no offline caching, no Room in v1" decision stands; this is its one named exception, settled with the org lead. An inbox is a short, read-only, newest-first list with a server-side cursor and no joins, so what "works offline" needs is the newest page and the badge — one JSON blob in the DataStore the push code already uses.
  • pr-checks.yml now runs on PRs into edge too (§7.1 Q8, answered "fix it"). All nine M12 phase PRs merged with zero CI; this PR is the first phase PR in the workstream that gets a real build. sonarqube.yml is untouched — it is a push-on-main analysis, not a PR gate.

The finding worth reading

Phase 7's contract says an item's url is relative-only — right for a browser already on the site, and a dead link on a phone. The first cut only opened http(s)-prefixed strings, so on the live rig every link in the inbox did nothing at all. InboxViewModel.linkFor now resolves against the configured base with HttpUrl.resolve, which absolutises the path and returns null for anything that would not end up http(s), so a javascript: or intent: url in a notification body opens nothing. The contract did not change; the client half of it was simply never written down.

The snapshot is scoped to (base URL, user id), and that is the security property — not the clear-on-logout beside the push deregistration. A cache is only handed back to the pair that wrote it, so the teardown paths that never reach a logout (a dead refresh token, a server switch) cannot surface one person's notifications under another's session. There is a test that fails if that scoping is removed.

Verified

./gradlew testDebugUnitTest lintDebug assembleDebug green — 508 tests, 0 failures.

On the live rig (emulator + the real server on edge, rows emitted through ctx.events.emit → engine → outbox → inappChannel, never hand-written):

  • drawer badge showed 3; the inbox listed the items newest-first with unread dots and local-zone timestamps; a tap marked one read (3 → 2, server row updated);
  • tapping an item opened its resolved link in a Custom Tab at the configured shard;
  • one email chip wrote exactly one row(35, news.post, email, instant) — and disturbed nothing else;
  • push controls correctly absent (the rig's shard advertises no relay), with the reason beside the list rather than replacing the screen;
  • network cut → all five items from the snapshot under "Offline — showing what was saved on this device.", unread count and read flags intact;
  • a simulated tapped tickle (STREAM=team.forum.post, REF=notification:5, the exact extras PushNotifier builds) landed on the inbox rather than Home.

One thing deliberately NOT fixed

Offline works in a running app, not on a cold start — and the reason is the app shell, not the inbox: MainActivity gates the whole of RunicApp on loading the site's appearance (M12), so an offline launch shows "Can't reach the site / Retry" and never reaches the drawer. Widening this phase into the shell's startup model is a decision for the org lead, so it is written up in PLAN.md §7 and ENGAGEMENT.md rather than done here.


  • AI-assisted: written with Claude Code (Claude Opus).

🤖 Generated with Claude Code

The app's half of the in-app channel. Phase 7 shipped four inbox routes with **no consumer on either platform**; this is the Android one, plus the per-channel preferences Phase 3 added and the shipped screen could not express. Design of record: `docs/website/ENGAGEMENT.md` Phase 8 (docs#190). Four decisions were settled by the org lead before any code. ## What changed - **The drawer's "Notifications" is the INBOX now**, with the preferences one tap away behind its gear — the arrangement Phase 7 shipped on the web (the bare path is the content, `…/settings` the preferences), and what a person means when they tap the word. - **The settings screen moved onto `/notifications/channels`.** Controls are rendered from the wire: a control per channel in **that item's** `channels`, its shape from **that channel's** `modes` — a switch for two, chips for three, so email's `digest` reaches the app and a fourth channel would too, with no release. A trigger-only id shows no push control rather than a dead switch. Each change is one sparse PUT of one pair. The old endpoint is the push projection of the new table server-side, so the shipped APK kept working the whole time. - **A tapped tickle routes on its `ref`, not its stream.** An engagement rule's tickle carries the TRIGGER id as `stream` (§7.2's one namespace) and `PushStreams` knows only the eight push streams, so `team.forum.post` would have landed on Home. `Routes.forTickle` sends anything whose ref starts with `notification:` to the inbox and leaves every other tickle where it has always gone. The ref is never decoded past that prefix and never rendered — it is a hint that a row exists, and the contract stays wake-and-pull, exactly as `pushChannel.js` says. - **Offline snapshot, and still no Room.** `PLAN.md` §7's "no offline caching, no Room in v1" decision stands; this is its one named exception, settled with the org lead. An inbox is a short, read-only, newest-first list with a server-side cursor and no joins, so what "works offline" needs is the newest page and the badge — one JSON blob in the DataStore the push code already uses. - **`pr-checks.yml` now runs on PRs into `edge` too** (§7.1 Q8, answered "fix it"). All nine M12 phase PRs merged with zero CI; this PR is the first phase PR in the workstream that gets a real build. `sonarqube.yml` is untouched — it is a push-on-`main` analysis, not a PR gate. ## The finding worth reading **Phase 7's contract says an item's `url` is relative-only** — right for a browser already on the site, and a dead link on a phone. The first cut only opened `http(s)`-prefixed strings, so on the live rig **every link in the inbox did nothing at all**. `InboxViewModel.linkFor` now resolves against the configured base with `HttpUrl.resolve`, which absolutises the path *and* returns null for anything that would not end up http(s), so a `javascript:` or `intent:` url in a notification body opens nothing. The contract did not change; the client half of it was simply never written down. **The snapshot is scoped to (base URL, user id), and that is the security property** — not the clear-on-logout beside the push deregistration. A cache is only handed back to the pair that wrote it, so the teardown paths that never reach a logout (a dead refresh token, a server switch) cannot surface one person's notifications under another's session. There is a test that fails if that scoping is removed. ## Verified `./gradlew testDebugUnitTest lintDebug assembleDebug` green — **508 tests**, 0 failures. On the live rig (emulator + the real server on `edge`, rows emitted through `ctx.events.emit` → engine → outbox → `inappChannel`, never hand-written): - drawer badge showed **3**; the inbox listed the items newest-first with unread dots and local-zone timestamps; a tap marked one read (3 → 2, server row updated); - tapping an item opened its **resolved** link in a Custom Tab at the configured shard; - one email chip wrote **exactly one row** — `(35, news.post, email, instant)` — and disturbed nothing else; - push controls correctly **absent** (the rig's shard advertises no relay), with the reason beside the list rather than replacing the screen; - network cut → all five items from the snapshot under "Offline — showing what was saved on this device.", unread count and read flags intact; - a simulated tapped tickle (`STREAM=team.forum.post`, `REF=notification:5`, the exact extras `PushNotifier` builds) landed on the inbox rather than Home. ## One thing deliberately NOT fixed **Offline works in a running app, not on a cold start** — and the reason is the app shell, not the inbox: `MainActivity` gates the whole of `RunicApp` on loading the site's appearance (M12), so an offline launch shows "Can't reach the site / Retry" and never reaches the drawer. Widening this phase into the shell's startup model is a decision for the org lead, so it is written up in `PLAN.md` §7 and `ENGAGEMENT.md` rather than done here. --- - [x] AI-assisted: written with Claude Code (Claude Opus). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 3 commits 2026-08-31 14:28:43 +00:00
ENGAGEMENT.md §7.1 Q8. `pr-checks.yml` triggered only on PRs into `main`, so a
workstream that lands its phases on `edge` before one cutover PR got no CI at
all until the cutover — all nine M12 phase PRs merged without a single run, and
engagement Phase 8 was about to do the same. A phase should fail on its own PR.

Sonar is untouched: `sonarqube.yml` is a push-on-`main` analysis, not a PR gate,
so no phase PR was ever expected to run it.

Co-Authored-By: Claude <noreply@anthropic.com>
The app's half of the in-app channel. Phase 7 shipped four inbox routes with no
consumer on either platform; this is the Android one, plus the per-channel
preferences Phase 3 added and the shipped screen could not express.

The drawer's "Notifications" is the INBOX now, with the preferences one tap away
behind its gear — the arrangement Phase 7 shipped on the web, and what a person
means when they tap the word. The settings screen moved off
/notifications/subscriptions onto /notifications/channels: it renders a control
per channel that applies to each id (from the item's own `channels`, never a
hardcoded three) and per mode that channel accepts, which is how email's
`digest` reaches the app. The old endpoint is the push projection of the new
table server-side, so the shipped APK went on working the whole time.

A tapped tickle whose `ref` starts with `notification:` lands on the inbox
whatever its stream is — an engagement rule's stream id is a TRIGGER id in the
one namespace, and `forStream`'s fixed map would have sent most of them Home.
Every other tickle keeps the route it has always had. The ref is not decoded
beyond that prefix and never rendered: it is a hint that a row exists, and the
contract stays wake-and-pull.

PLAN.md §7's "no Room cache in v1" stands; the offline snapshot is its one named
exception, settled with the org lead. The inbox is a short, read-only,
newest-first list with a server-side cursor, so what "works offline" needs is the
newest page and the badge, not a database — one JSON blob in the DataStore the
push code already uses. Every snapshot is scoped to (base URL, user id) and only
handed back to that pair: that, not the clear-on-logout, is what stops a cache
surviving into another account on the paths that never reach a logout at all.

Co-Authored-By: Claude <noreply@anthropic.com>
fix(notifications): resolve an item's relative url, and document the CI trigger
Some checks failed
PR Checks / android-build (pull_request) Failing after 42m5s
21b6ddc29b
Two things the live rig found, and the README half of the trigger change.

Phase 7 specifies an inbox item's `url` is RELATIVE-ONLY and validates it as
such — right for a browser already on the site, a dead link on a phone. The
first cut here only opened `http(s)`-prefixed strings, so on the rig every link
in the inbox did nothing at all. `InboxViewModel.linkFor` now resolves against
the configured base with OkHttp's `HttpUrl.resolve`, which absolutises the path
and returns null for anything that would not end up http(s) — so a `javascript:`
or `intent:` url in a notification body opens nothing.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit d3bf4853de into edge 2026-08-31 14:37:36 +00:00
whitlocktech deleted branch feature/engagement-inapp-android 2026-08-31 14:37:37 +00:00
Sign in to join this conversation.
No description provided.