docs(engagement): the in-app channel on Android, as built (Phase 8) #190

Merged
whitlocktech merged 1 commits from docs/engagement-inapp-android into edge 2026-08-31 14:36:36 +00:00
2 changed files with 154 additions and 20 deletions

View File

@@ -759,6 +759,18 @@ Mirrors the website's "degrade gracefully" invariant:
loading/error/retry states; it does **not** ship a Room cache in v1. Cached read-only content can be loading/error/retry states; it does **not** ship a Room cache in v1. Cached read-only content can be
added later without reworking the repository layer (its typed results already isolate the UI from the added later without reworking the repository layer (its typed results already isolate the UI from the
data source). No `Room` dependency in the initial build. data source). No `Room` dependency in the initial build.
- **Amended 2026-08-31 (engagement Phase 8): one named exception, and still no Room.** The in-app
inbox keeps an offline snapshot - `core/inbox/InboxCache`, one JSON blob in the DataStore the push
code already uses, capped at the server's own default page size. 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, not a database. **Every snapshot is scoped to (base URL, user id)** and
handed back only to that pair, which is what stops one account's notifications surfacing under
another's session on the teardown paths that never reach a logout (a dead refresh token, a server
switch); the clear-on-logout beside the push deregistration is the tidy-up, not the safeguard.
**The known limit: this serves a running app, not a cold start.** `MainActivity` gates the whole of
`RunicApp` on loading the site's appearance, so an offline launch still shows the shell's "Can't
reach the site / Retry" and never reaches the drawer. Changing that is a change to the shell's
startup model, and it was left for the org lead rather than widened into Phase 8.
--- ---
@@ -1307,9 +1319,48 @@ Four properties the UI should be built on rather than around:
`ceiling` is `staff` is not offered to a non-staff caller — it can never reach them, and listing it `ceiling` is `staff` is not offered to a non-staff caller — it can never reach them, and listing it
would disclose that the event exists. `GET /notifications/streams` is unfiltered and unchanged. would disclose that the event exists. `GET /notifications/streams` is unfiltered and unchanged.
**Phase 8** (`ENGAGEMENT.md`) is where the app grows the in-app inbox and this screen gains the ### The inbox and the per-channel screen - as built (engagement Phase 8, 2026-08-31)
per-channel toggles. Until then the existing per-stream screen keeps working against
`/notifications/subscriptions` unmodified. **The drawer's "Notifications" is the INBOX now**, and the preferences are one tap away behind its
gear - the arrangement Phase 7 shipped on the web (`/account/notifications` is the content,
`.../settings` the preferences), and what a person means when they tap the word. `Routes.NOTIFICATIONS`
is unchanged and `Routes.NOTIFICATIONS_SETTINGS` is new, so an admin's nav override pointing at the
old route still lands somewhere sensible.
**The inbox** (`ui/notifications/InboxScreen` + `InboxViewModel`) reads the four routes Phase 7
shipped: a keyset page on `before` (never an offset - the list gains rows at the top while it is being
read), the unread count that rides along on every page, and the two mark-read writes. Reads are
optimistic and deliberately not rolled back on failure; a local read also rewrites the snapshot, or
going offline right after reading everything would bring the badge back on the next cold open. The
drawer badge has its own view model on `/notifications/unread-count`, refreshed on resume rather than
on a timer - the tickle is what says "something happened", so polling would be a second, worse copy
of push.
**Two things the app has to do that the backend contract does not state:**
- **Resolve the item's `url`.** Phase 7 specifies it is **relative-only** (`/guilds/.../forum/403`),
which is right for a browser already on the site and a dead link on a phone.
`InboxViewModel.linkFor` resolves it 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. The live rig is what caught
this: the first cut only opened `http(s)`-prefixed strings, so every link in the inbox did nothing
at all.
- **Route the tickle on its `ref`, not its stream.** An engagement rule's tickle carries the TRIGGER
id as `stream` (ENGAGEMENT.md section 7.2's one namespace) and `PushStreams` knows only the eight
push streams, so `team.forum.post` would have landed on Home. `Routes.forTickle(stream, ref)` sends
anything whose ref starts with `notification:` to the inbox and leaves every other tickle on the
route it has always had. 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.
**The settings screen** (`NotificationSettingsScreen` + `NotificationSettingsViewModel`) moved off
`/notifications/subscriptions` onto `/notifications/channels`. Controls are rendered from the wire:
one row per subscribable id, a control per channel in **that item's** `channels`, and its shape from
**that channel's** `modes` - a switch for two modes, chips for three, so email's `digest` reaches the
app and a fourth channel would too, without a release. A trigger-only id shows no push control rather
than a dead switch, and on a shard with no push relay the push controls are absent with the reason in
a note beside the list (email and on-site preferences are still worth setting there). Each change is
one sparse PUT of one pair, and the screen re-renders from the response, so an entry the server drops
shows up as the control springing back.
## 12. Build & CI (Gitea Actions) ## 12. Build & CI (Gitea Actions)

View File

@@ -1,14 +1,18 @@
# The Engagement System — findings and plan # The Engagement System — findings and plan
**Status:** design of record. **Phases 1, 1a, 1b, 2, 3, 4a and 4b are built** (Phase 1: website#165 + **Status:** design of record. **Phases 1, 1a, 1b, 2, 3, 4a, 4b, 5a, 5b, 6, 7 and 8 are built**
docs#178, with website#164 as its prerequisite; Phase 1a: website#166 + docs#179; Phase 1b: (Phase 1: website#165 + docs#178, with website#164 as its prerequisite; Phase 1a: website#166 +
website#167 + docs#180; Phase 2: website#168 + docs#181; Phase 3: website#169 + docs#182; Phase 4a: docs#179; Phase 1b: website#167 + docs#180; Phase 2: website#168 + docs#181; Phase 3: website#169 +
website#170 + docs#183; Phase 4b: website#171 + docs#184); everything from Phase 5 on is still design. docs#182; Phase 4a: website#170 + docs#183; Phase 4b: website#171 + docs#184; Phase 5a: website#172 +
The scope decisions below are settled; **seven of the eight questions in §7.1 are answered** — Q1, Q3, docs#185; Phase 5b: website#173 + docs#186 + runicgateway.com#22; Phase 6: website#174 + docs#187 +
Q5 and Q7 on 2026-08-28, Q6 on 2026-08-29 at the start of Phase 2 (which also settled §7.2's runicgateway.com#23; Phase 7: website#175 + docs#188 + runicgateway.com#24; Phase 8: the Android
namespace question), and **Q2 and Q4 on 2026-08-29 at the start of Phase 4**. Q1's answer added a half, Android-app#42 + docs#190); everything from Phase 9 on is still design. The scope decisions
whole phase (**Phase 1b**, unique email addresses); Q4's answer and the phase's size split **Phase 4 below are settled; **eight of the nine questions in §7.1 are answered** - Q1, Q3, Q5 and Q7 on
into 4a and 4b**. **Q8** remains open and blocks Phase 8. Per CLAUDE.md § Conventions, no 2026-08-28, Q6 on 2026-08-29 at the start of Phase 2 (which also settled §7.2's namespace question),
**Q2 and Q4 on 2026-08-29 at the start of Phase 4**, and **Q8 on 2026-08-31 at the start of Phase
8**. Q1's answer added a whole phase (**Phase 1b**, unique email addresses); Q4's answer and the
phase's size split **Phase 4 into 4a and 4b**; **Q9** (core's own `news.post` emitter, added
2026-08-31 in docs#189 and recommended for Phase 11) is the only one still open. Per CLAUDE.md § Conventions, no
implementation starts without the org lead's approval of the phase it belongs to. implementation starts without the org lead's approval of the phase it belongs to.
**Branching:** every phase lands on **`edge`** in its repo; `main` is touched once, by the cutover **Branching:** every phase lands on **`edge`** in its repo; `main` is touched once, by the cutover
@@ -1335,7 +1339,7 @@ change is not complete until `docs/` reflects it" — is the floor; this table i
| **5b** The editor | `website/ENGAGEMENT.md` §4.6.2 as built · `BACKEND_DESIGN.md` route table | **`runicgateway.com`**: a new admin docs page for the template editor | | **5b** The editor | `website/ENGAGEMENT.md` §4.6.2 as built · `BACKEND_DESIGN.md` route table | **`runicgateway.com`**: a new admin docs page for the template editor |
| **6** Email channel + Teams migration ✅ | `website/TEAMS.md` §6.3/§6.4 **rewritten** — the Team pipeline it describes no longer exists as its own thing · `website/ENGAGEMENT.md` §4.2b + this phase as built · `BACKEND_DESIGN.md` route table and table inventory | **`runicgateway.com`**: `administration/teams.mdx` notification section. Landed with the phase | | **6** Email channel + Teams migration ✅ | `website/TEAMS.md` §6.3/§6.4 **rewritten** — the Team pipeline it describes no longer exists as its own thing · `website/ENGAGEMENT.md` §4.2b + this phase as built · `BACKEND_DESIGN.md` route table and table inventory | **`runicgateway.com`**: `administration/teams.mdx` notification section. Landed with the phase |
| **7** In-app channel (core+web) ✅ | `website/BACKEND_DESIGN.md` routes + tables (the four inbox routes, `user_notifications`) · `website/ENGAGEMENT.md` this phase as built | **`runicgateway.com`**: `notifications-and-email.mdx` gains the in-app channel. Landed with the phase | | **7** In-app channel (core+web) ✅ | `website/BACKEND_DESIGN.md` routes + tables (the four inbox routes, `user_notifications`) · `website/ENGAGEMENT.md` this phase as built | **`runicgateway.com`**: `notifications-and-email.mdx` gains the in-app channel. Landed with the phase |
| **8** In-app (Android) | `android/PLAN.md` | `android-app/README.md` | | **8** In-app (Android) | `android/PLAN.md` §7 (the Room exception) + §11 (the inbox as built) · `website/ENGAGEMENT.md` this phase as built | `android-app/README.md`. Landed with the phase |
| **9** Deliverability | `website/BACKEND_DESIGN.md` §7 · a suppression/bounce operator section (the verification flow is Phase 1b's) | **`runicgateway.com`**: `troubleshooting.mdx` gains bounce/suppression · **`PLAY_DATA_SAFETY.md` + `/privacy`** — see Phase 12 | | **9** Deliverability | `website/BACKEND_DESIGN.md` §7 · a suppression/bounce operator section (the verification flow is Phase 1b's) | **`runicgateway.com`**: `troubleshooting.mdx` gains bounce/suppression · **`PLAY_DATA_SAFETY.md` + `/privacy`** — see Phase 12 |
| **10** Protocol bump | `link/INTEGRATION.md` §Housing (table + example) · `link/PLAN.md` §5/§7 · a `link/v5.md` if the bump earns its own design doc, as v3 and v4 did | `servuo-plugins/overlay.toml` · **`runicgateway.com`**: `platform.json.protocol` → 5, `bundle.*`, `architecture/protocol-versions.mdx` | | **10** Protocol bump | `link/INTEGRATION.md` §Housing (table + example) · `link/PLAN.md` §5/§7 · a `link/v5.md` if the bump earns its own design doc, as v3 and v4 did | `servuo-plugins/overlay.toml` · **`runicgateway.com`**: `platform.json.protocol` → 5, `bundle.*`, `architecture/protocol-versions.mdx` |
| **11** module-uo triggers | `modules/uo/API.md` · `modules/uo/README.md` | `module-uo/README.md` | | **11** module-uo triggers | `modules/uo/API.md` · `modules/uo/README.md` | `module-uo/README.md` |
@@ -2774,7 +2778,7 @@ suppression list, which this channel has no equivalent of — there is no addres
--- ---
### Phase 8 — The in-app channel (Android) ### Phase 8 — The in-app channel (Android)
Inbox screen, unread badge, and the tickle → pull → inbox path. App-store cadence, separate repo, Inbox screen, unread badge, and the tickle → pull → inbox path. App-store cadence, separate repo,
separate release. separate release.
@@ -2782,7 +2786,85 @@ separate release.
**Acceptance:** a tickle wakes the app, which pulls and shows the item; the inbox works offline from **Acceptance:** a tickle wakes the app, which pulls and shows the item; the inbox works offline from
cache; the existing preferences screen gains the per-channel toggles from Phase 3. cache; the existing preferences screen gains the per-channel toggles from Phase 3.
**Note:** `android-app`'s `pr-checks.yml` triggers only on PRs into `main`, so phase PRs onto a working **Note:** `android-app`'s `pr-checks.yml` triggers only on PRs into `main`, so phase PRs onto a working
branch get **no CI** — the cutover PR is the first real run. Plan for that. branch get **no CI** — the cutover PR is the first real run. Plan for that. **Resolved: §7.1 Q8 was
answered "fix it" and the trigger now runs on PRs into `edge` too, as this phase's first commit.**
#### As built — 8 (2026-08-31)
The four inbox routes Phase 7 shipped had no consumer on either platform; this is the Android one.
**Four decisions were settled by the org lead before any code**, and one of them resolves a
contradiction the phase carried from the day it was written.
1. **§7.1 Q8 — fix the CI trigger, as the first commit.** `pr-checks.yml` now runs on PRs into
`edge` as well as `main`. All nine M12 phase PRs merged with no CI at all and engagement Phase 8
was about to repeat it; the alternative was finding a Kotlin compile error inside the cutover
window with a whole workstream's diff to bisect. `sonarqube.yml` is untouched — it is a
push-on-`main` analysis, not a PR gate, so no phase PR was ever expected to run it.
2. **The offline snapshot is a JSON blob, not Room.** This phase's acceptance line ("the inbox works
offline from cache") **contradicted a decision already recorded in `docs/android/PLAN.md` §7**:
*"Offline caching is not a v1 requirement (decided)… does not ship a Room cache in v1. No `Room`
dependency in the initial build."* §7's decision stands and this is its one named exception. 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 blob in the DataStore the push code
already uses, capped at the server's own default page size.
3. **The drawer's "Notifications" is the inbox; the settings are behind its gear.** Exactly 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.
4. **The settings screen moved onto `/notifications/channels`**, rendering a control per channel that
applies to each id and per mode that channel accepts.
**The one thing the app must do that no plan said: resolve the item's `url`.** Phase 7's acceptance
specifies `url` is **relative-only** and validates it as such — right for a browser already on the
site, and a dead link on a phone. The live rig caught it: every item came back as
`/guilds/the-silver-anvil/forum/403`, and the app's first cut only opened `http(s)`-prefixed strings,
so **every link in the inbox did nothing**. It 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 at all. The
relative-only contract did not change; the client half of it was simply never written down.
**Three further things the build settled:**
- **The tickle's `ref` is what routes it, not its stream.** An engagement rule's tickle carries the
TRIGGER id as `stream` (§7.2's one namespace), and the app's shipped `forStream` map knows only the
eight push streams — so `team.forum.post` would have landed on Home. `Routes.forTickle(stream, ref)`
sends anything whose ref starts with `notification:` to the inbox and leaves every other tickle on
the route it has always had. The ref is not decoded past that prefix and is never rendered: it is a
hint that a row exists, and the contract stays wake-and-pull, exactly as `pushChannel.js` says.
- **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 ever handed back to the pair that
wrote it, so the teardown paths that never reach a logout at all (a dead refresh token, a server
switch) cannot surface one person's notifications under another's session.
- **Reads are optimistic and are not rolled back.** The row flips locally, the server's post-write
`unread` replaces the local guess, and a failure is left alone: un-reading a row under someone's
finger looks like a bug, and the next refresh corrects it. A local read also rewrites the snapshot,
without which going offline right after reading everything would bring the badge back on the next
cold open.
**Verified on the live rig** (throwaway emulator + the real server on `edge` + real rows emitted
through `ctx.events.emit` → engine → outbox → `inappChannel`, never hand-written):
- the drawer badge showed **3**, the inbox listed the three items newest-first with unread dots and
local-zone timestamps, and a tap marked one read (3 → 2, server-side 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, which is the sparse PUT's whole point;
- the push controls were **absent**, correctly, because the rig's shard advertises no relay, with the
reason in a note beside the list rather than replacing the screen (email and on-site preferences are
still worth setting on a shard with no push);
- with the network cut, the inbox rendered 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 finding this phase did NOT fix, deliberately.** *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 at all. Widening this phase into the shell's startup model is the kind of
out-of-scope blocker that is a reason to ask, not a licence to widen the PR, so it is written up here
for the org lead. The acceptance line holds for the case the cache exists to serve — the app you were
just using, on a train — and fails for the case where the app was killed first.
**Still later phases':** nothing of this channel. The app now consumes every route Phase 7 built.
--- ---
@@ -3033,11 +3115,12 @@ day it ships.
Composition must **narrow, never widen** — the segment takes the tightest ceiling it contains and is Composition must **narrow, never widen** — the segment takes the tightest ceiling it contains and is
still checked against the trigger's G24 ceiling. §5.1a is the design; Phase 2 owns the surface, still checked against the trigger's G24 ceiling. §5.1a is the design; Phase 2 owns the surface,
Phase 4 the composition UI, Phase 11 `module-uo`'s first real audiences. Phase 4 the composition UI, Phase 11 `module-uo`'s first real audiences.
8. **Android CI on `edge`** (§6.0a). `android-app/.gitea/workflows/pr-checks.yml` triggers only on PRs 8. **Android CI on `edge`** (§6.0a). **ANSWERED 2026-08-31 (org lead), at the start of Phase 8: fix
into `main`, so Phase 8 lands with zero CI and Phase 13 is its first real build — as happened to all it, as the phase's first commit.** `pr-checks.yml` triggered only on PRs into `main`, so Phase 8
nine M12 phase PRs. Fix the trigger as Phase 8's first commit, or accept it deliberately? would have landed with zero CI and Phase 13 would have been its first real build — as happened to
Recommendation: fix it. It is a two-line workflow change and the alternative is finding out about a all nine M12 phase PRs. `pull_request.branches` is now `[main, edge]`. **`sonarqube.yml` was
Kotlin compile error during the cutover window. deliberately left alone**: it is a push-on-`main` analysis rather than a PR gate, so no phase PR
was ever expected to run it.
9. **Core's own `news.post` emitter — and the three other things a publish already fires.** 9. **Core's own `news.post` emitter — and the three other things a publish already fires.**
`config/coreTriggers.js` declares core's five triggers and says in as many words that **nothing here `config/coreTriggers.js` declares core's five triggers and says in as many words that **nothing here
emits yet**; Phase 6 migrated only the four `team.*` ones onto the engine. So `news.post` is a emits yet**; Phase 6 migrated only the four `team.*` ones onto the engine. So `news.post` is a