Merge pull request 'docs(rust): phase 11 — a Rust notification on a phone (PLAN.md §26, android M16)' (#266) from docs/rust-phase-11 into main

Reviewed-on: #266
This commit is contained in:
2026-09-23 20:49:14 +00:00
2 changed files with 230 additions and 5 deletions

View File

@@ -229,7 +229,8 @@ Verified green: `:app:testDebugUnitTest` (18 new JVM tests — notifications DTO
parse incl. malformed, topic/URL building, stream→route map + personal gating) + `:app:lintDebug` +
`:app:assembleDebug`; backend 250 tests (+3 for `push.ntfyUrl`) and `npm run swagger` clean. The
foreground-service tradeoff (§11) and the POST_NOTIFICATIONS runtime permission are implemented as
planned; an on-device delivery pass against a live ntfy is the one open QA item.
planned; an on-device delivery pass against a live ntfy is the one open QA item. *(Closed by M16 on
2026-09-23: a raid alert delivered through `ntfy.sh` to an emulator, 19 seconds end to end.)*
**Part 2 (original plan) — the Android app.** UnifiedPush receiver + device registration
against the merged Part-1 contract, a Notifications settings screen, and notification-tap deep-links.
@@ -313,7 +314,10 @@ Work items:
`GET …/streams` with a per-stream toggle bound to `GET/PUT …/subscriptions`; a **personal** stream
(`requiresLinkedAccount`) is greyed with a "link a game account" hint until the user has a linked
account — reuse the linked-accounts signal already fetched for M4's player surface
(`PlayerShardRepository`), not a fresh source of truth. Toggling to a non-empty set triggers the
(`PlayerShardRepository`), not a fresh source of truth. *(M16 amends both halves: the signal is now
asked of the site's own module by capability (`LinkedAccountRepository`), because on a Rust site
`PlayerShardRepository` answered no for everyone. It holds back only switching push **on**, and no
longer greys the whole row.)* Toggling to a non-empty set triggers the
register flow (#5) and requests `POST_NOTIFICATIONS`; emptying the set unregisters. Each mutation
folds its `ApiResult` into a section-scoped, localized banner (§7 parity with M4).
7. **Deep-links (resolves the §13 open item).** Tapping a notification opens the app to the stream's
@@ -324,7 +328,8 @@ Work items:
`label`) and deep-links — it does **not** pull `ref` content first; the content-free design means
nothing needs decrypting to render the tap, and the target screen fetches fresh over the
authenticated API on open. (Pulling `ref` for a richer inline notification is a possible later
enhancement, not v1.)
enhancement, not v1.) *(M16 did it for `notification:<id>` refs: `PushContentResolver` pulls the
inbox row and titles the notification with it. Every other ref keeps the generic title.)*
8. **Menu.** Add a **Notifications** entry to the signed-in group in `ui/navigation/Menu.kt` (near My
Account), visible once signed in.
9. **Permission UX.** Request `POST_NOTIFICATIONS` at the moment the user first enables a stream (API
@@ -1528,6 +1533,52 @@ push, and Play (M6M8) follow the designed app.
once the fleet was widened to signed-in — the app's bearer session reaching the module's viewer
check. Branch `fix/rust-presence-visibility`.
17. **M16 — a Rust notification on a phone** (post-v1; built 2026-09-23). `module-rust` phase
11, R10's leg C: the app half of phase 10's notifications. **Design of record:
[`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §26**, with its four decisions (D69D72).
The inbox (engagement phase 8) and the preferences screen (engagement phase 3) are drawn from the
wire, so every Rust trigger already arrives as a row, with no app change. This milestone covers
the three places where the app still assumed it was talking to a UO shard:
- **The link check** behind the *Your game account* section asked `module-uo`'s
`/player/shard/accounts`. On a Rust site that locks the raid alert in every channel. It will
ask the site's own module, chosen by capability (`rust``/player/rust/links`). Switching a
channel **off** is never gated, and only switching push **on** needs a link.
- **A push is titled from the item it points at.** A `notification:<id>` ref is pulled over the
authenticated inbox API, and the relay still carries nothing. The per-stream title stays as the
fallback and as the lock-screen version. The M7 items that said nothing is
fetched to show a notification are amended in place.
- **`/player/rust` and `/rust/servers/<id>?tab=<tab>` open natively.** M15 deferred the first
of these to exactly this phase.
It also closes **M7's open QA item**. On-device delivery against a live ntfy has never been
walked. It is walked here, on `ntfy.sh` (D72).
#### As built (2026-09-23)
Branch `feat/rust-phase-11-notifications`. The app suite is **672 tests, 0 failures**, up from
657, with `lintDebug` and `assembleDebug` green. It was walked on the `s22_ultra` emulator
against real core on `main`, with the module installed, the protocol-7 sidecar inside the Oxide
rig's container, and the relay on `ntfy.sh`:
- **Linked (raidowner1):** the raid alert's push switched on, and the device registered
`https://ntfy.sh/<topic>`. A door raided with phase 10's rig helper reached the phone
**19 seconds** later as *"Your base is being raided — A door was destroyed in P16 on Oxide
rig."* The notification was `vis=PRIVATE`, with a public version carrying only the generic
title. Tapping it opened the inbox, and the row opened the Oxide rig's screen natively.
- **Switched off in the app:** push off, and the next raid enqueued in-app only. The inbox row
arrived and no system notification was posted. With nothing else on push, the device was
unregistered and the push service stopped.
- **Not linked (walkadmin):** push held back with the hint, while email and in-app stayed live.
In-app was switched off, stored as `off`, and switched back on.
- **D71:** a row linking `?tab=leaderboard` opened on the Leaderboard tab, and `/player/rust`
opened *My Rust account*. `?tab=clans` went to a Custom Tab.
**Not walked:** a UO site (no UO core was running; the path is the call it always was, and a test
pins it), and the lock-screen rendering (the emulator has no screen lock; the public version was
read back from `dumpsys notification`).
### Deferred (not a milestone)
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is
@@ -1750,7 +1801,9 @@ of push.
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.
that a row exists, and the contract stays wake-and-pull. *(M16 decodes it once more, to the id,
and only to pull that row over the authenticated inbox API and title the notification with it. It is
still never rendered.)*
**The settings screen** (`NotificationSettingsScreen` + `NotificationSettingsViewModel`) moved off
`/notifications/subscriptions` onto `/notifications/channels`. Controls are rendered from the wire:

View File

@@ -997,7 +997,7 @@ Each phase ends with its findings written down, as every workstream here does.
| 8 | **Android leg B** (R10), and the half of R2 a player may see. ✅ **Built 2026-09-22 — as built and findings in §22.** One drawer row under the player group shaped like `module-uo`'s own (the code card first, then what the code got them), gated on `rust` and `PLAYER`; the four refusals kept four pieces of advice; and **one new website route**, `GET /player/rust/permissions`, because phase 7 gave an operator every view of in-game privilege and a player none. Three org-lead decisions (§22.0), the scope arithmetic answered on the server so `inScope` exists once (§22.1), and **the rig moved the sidecar inside the game container** ([`INSTALL_RIG.md`](../../rust-link/INSTALL_RIG.md)), which retired the firewall wall phases 6, 7 and 7b each stopped at | Android-app + Module-Rust + docs | A player links from the app |
| 9 | **Teams from first-party clans** (R5). *Preceded by the presence fix (§23), which this phase's roster question produced.***Built 2026-09-23 — plan, as built and findings in §24** (D47D58). Walked on the Oxide rig end to end and on Carbon hook by hook; the game's 100-clan ceiling accepted (D55); **one module per site** became a core rule on the way (§24.5). Membership event-driven, leadership read off `LocalClan` at snapshot; **`declareModuleSlot` × 3** for core's `team.notify` / `team.activity` / `team.forum` | Module-Rust + 2 | The clan page is ours, core's contributions land in places we named, and every slot empty still reads correctly |
| 10 | **Notifications and engagement** (R7). ✅ **Built and walked 2026-09-23 — plan, as built and findings in §25 (D59D68).** The criterion walked on the Oxide rig (two authorised owners alerted once each; bystander, raider and an unlinked owner told nothing), and the same plugin loaded on Carbon; a live walk found that every generic notice never said which server (§25.6). Streams, triggers with `ceiling` and `subjectKey`, audiences and engagement seeds — **the catalogue is §10, as corrected by §25.2**. The announce leg and the post hook are **not** in it (D62 amends R7), and protocol 7 widens the raid frame, which brings the two bridge repositories in | all 3 + docs | The offline raid alert reaches the player whose base it was, and nobody else |
| 11 | **Android leg C** (R10). Inbox and notification preferences for Rust triggers | Android-app | A Rust notification arrives on a phone and can be switched off there |
| 11 | **Android leg C** (R10). **Built and walked 2026-09-23 — plan, as built and findings in §26 (D69D72).** The link check behind personal streams asked `module-uo` and locked the raid alert on every Rust site; it now asks the site's own module and holds back only push-on. A tickle is titled from the inbox row it names, and two links stopped opening the browser. **The relay hop was walked on `ntfy.sh` for the first time on any site**: a rig raid reached the emulator in 19 seconds, and after push was switched off in the app the next raid enqueued in-app only | Android-app + docs | A Rust notification arrives on a phone and can be switched off there |
| 12 | **Events: budgets, option sources and the leases** (§9). [kit][kit] ch. 5's own ordering — leases before actions — and every key verified live before it is advertised | Module-Rust + 2 | A leased value is observed changing in the running game and restored, per key; `rust.group.membership` expires without core asking |
| 13 | **Events: the actions** (§9, R3, R16). `rust.kit.entitle` first, then `rust.prefab.place` and `rust.announce`; `reversible: 'ledger'`; the kit option source flags kits with no permission gate, plus **`reconcile()` and the boot-id watch calling `ctx.events.reconcile()`** (§11.1) | all 3 + docs | A reward granted at 03:00 is waiting in the kit menu when the player next logs in, and a revert withdraws it; a wipe reconciles the ledger instead of stranding it |
| 14 | **The live map** (R9). The map image over the bridge — request/reply, two-stage, one in flight, its own derivation version, no import on boot — plus the live layers and a per-layer public/players/admin switch built on **our own** visibility layer (§11.2 — `shardVisibility` is `module-uo`'s, not core's) | all 3 + docs | The map renders for the current wipe, and a player layer is invisible until an operator deliberately opens it |
@@ -3891,6 +3891,178 @@ the real `OnEntityDeath` path runs.
- A Python heredoc halved a backslash in a JavaScript string again; the Edit tool is the safe path
for anything with an apostrophe in it.
## 26. Phase 11 — Android leg C: a Rust notification on a phone (plan of record, 2026-09-23)
R10's third leg: the app half of phase 10. Almost nothing about it is Rust-specific, and that is
the first finding. The inbox (engagement phase 8) and the preferences screen (engagement phase 3)
are both drawn **from the wire**: every trigger phase 10 declared already appears as a row with its
own channels, and an inbox item pointing at `/rust/servers/<id>` already opens the native server
screen (phase 5 put that path in the app's web-path table). The phase is the three places where the
app still assumed the site it talks to is a UO shard, plus the relay walk nobody has run yet.
Two repositories: [Android-app][aa] into `edge`, and this document into `main`. **Module-Rust is not
expected to change**, and core is not: everything below is a client reading something the server
already says.
### 26.0 The decisions this phase needed
All from the org lead, 2026-09-23.
| # | Decision |
|---|---|
| **D69** | **"Has a linked game account" is asked of the site's own module.** The app uses the capabilities it already reads (D16): `rust` asks `GET /player/rust/links`, and `shard` asks `GET /player/shard/accounts` as it does today. A site with neither has no link. One module per site (§24.5) makes that one call, never a merge. **Switching a channel off is never gated.** The link gates only switching **push on** for a `requiresLinkedAccount` item, which is the one thing §11 of the Android plan gave the flag. Rejected: dropping the gate entirely (the web has none, and the server enforces none, so this was a fair option), and having core report `linked` per item (a contract member and a `MODULE_API` bump to answer a question the app can ask itself). |
| **D70** | **A tickle with a `notification:<id>` ref is titled from that inbox item, pulled over the authenticated API.** This is the wake-and-pull contract core's `pushChannel` states in as many words: the ref is there so the app can fetch the real item, and the relay never sees it. When the ref is null, the fetch fails, or the item is not on the page, the notification falls back to today's per-stream title. On the lock screen it shows only that fallback title. Rejected: titling from the catalogue's label (less specific, and it needs a cache that can go stale), and adding the four Rust ids to the app's fixed `PushStreams` list (it ties an app release to a module's trigger ids). |
| **D71** | **Two links that already have a native screen stop opening the browser.** `/player/rust` (the *account linked* notice) goes to `PLAYER_RUST`. `/rust/servers/<id>?tab=<tab>` (the *new leader* notice) opens the server on that tab, **only** when the query is exactly `tab=` and the value is a tab the app has. That is the rule `?run=` already follows for events. `?tab=clans` still goes to the browser, because the app has no clans tab. |
| **D72** | **The walk proves the relay hop on public `ntfy.sh`.** The org lead's instruction was to reuse whatever `module-uo` did first. It did nothing: the Android plan's §11 has listed *on-device delivery against a live ntfy* as M7's one open QA item since M7 landed, and no walk has closed it since. Core publishes only to a public HTTPS host (`pushDispatch.isAllowedEndpoint`), so a local relay on `127.0.0.1` is refused by design. What leaves the rig is `{ stream, ref }` on a random topic and nothing else, the same thing that leaves a production site. |
### 26.1 Facts the phase rests on
**From the app** (`Android-app`, `edge`):
- **`NotificationSettingsViewModel` decides "linked" by calling `PlayerShardRepository.accounts()`**,
which is `module-uo`'s `/player/shard/accounts`. On a Rust site that route does not exist, the call
fails, and a failure reads as *not linked*. The screen then disables the **whole row** of every
personal item. The raid alert can't be switched on or off in any channel, including in-app, which
the link has nothing to do with. The criterion's second half fails on this alone.
- **`PushNotifier` titles every tickle from `PushStreams.titleRes`**: eight fixed ids from M7, all
UO or core, and "New notification" for anything else. A raid alert arrives saying nothing.
- **`PushService` handles tickles inside `collectLatest`.** A second tickle cancels whatever the
handler for the first was still doing. That was harmless while the handler did not suspend. A pull
(D70) suspends, so each one must run in its own job, or a burst (two authorised owners' alerts, a
wipe and a restart) loses all but the last.
- **Tapping a tickle with a `notification:*` ref opens the inbox** (`Routes.forTickle`, engagement
phase 8), and the inbox opens the item's link natively when `resolveWebPath` knows it. Nothing
here changes that. D71 only widens what `resolveWebPath` knows.
- **The web page's tabs are `feed`, `leaderboard`, `online`, `wipes` and `clans`. The app has the
first four.**
**From core** (`website`, `main`):
- **There is no single-item inbox read.** The inbox is a keyset-paged list, so the pull reads the
first page and looks for the id. The item has just been written, so it is on that page. If it is
not, D70's fallback applies.
- **A GET of the inbox marks nothing read.** The pull leaves the badge as it was.
- **`requiresLinkedAccount` is not enforced anywhere on the server**, and the web settings screen
has no gate. The app's gate is a courtesy, not a boundary, and D69 keeps it one: what decides who
is alerted is phase 10's recipient computation (D59).
- **Push defaults off for engagement rules until a person opts in**, which §25.5 observed on the
rig. The criterion's first half therefore needs the app's toggle to work before anything can
arrive.
### 26.2 What the plan builds
**Android-app** (`feat/rust-phase-11-notifications`, from `edge`):
- **The link check (D69).** A small `LinkedAccountChecker` over `SiteCapabilitiesRepository`,
`PlayerRustRepository` and `PlayerShardRepository`, pure enough to test without Compose.
`itemSelectable` becomes a question about **one channel and one direction**: every channel is
live, and a push-on for a `requiresLinkedAccount` item is refused with the existing hint when
there is no link.
- **The pull (D70).** `PushService` launches each tickle in its own job on the service scope rather
than inside `collectLatest`. `PushNotifier` gains a suspend path: parse the `notification:<id>`
ref, read the first inbox page under a short timeout, and post with the item's title and body, or
with the per-stream title when anything is missing. `VISIBILITY_PRIVATE` with a public version
carrying only the fallback title. The ref parser is prefix-exact, the same rule as `forTickle`.
- **The two links (D71).** `resolveWebPath` learns `/player/rust`, and learns `tab` as the second
query key it honours, on the Rust server path only. `RUST_SERVER` gains an optional `tab`
argument, and the server screen opens on it.
- **Tests** for each of the three, including the one that matters most: a Rust site with a link
lets push be switched on for the raid alert, and a site with no link still lets every channel be
switched off.
**docs:** this section as built, the phase row, and the Android plan's M16 and §11. §11 had
said that nothing is fetched to show a notification, and it has to stop saying so.
### 26.3 Done when
The row's criterion: **a Rust notification arrives on a phone and can be switched off there.**
Walked on an emulator against real core on `main` with this module installed, and the Oxide rig's
sidecar behind it:
1. On a Rust site, the app's settings let a linked player switch the raid alert's push on. The same
screen with no link leaves push refused and every other channel live.
2. The device registers an endpoint on `ntfy.sh` (D72). A raid made with phase 10's rig helper
(`RgRaidRig.cs`) is delivered as a **system notification titled from the item**. It shows the
fallback title on the lock screen and opens the inbox when tapped. The inbox row opens the server
natively.
3. The player switches the raid alert's push off in the app. A second raid produces **no** system
notification, while the in-app row still arrives if in-app is still on.
4. A UO site is unchanged: its personal rows still gate on `/player/shard/accounts`.
If the relay hop cannot be made on `ntfy.sh`, it is written down as a walk still to run, and the
rest is proven with the tickle injected by `adb`, the extras `PushNotifier` builds.
### 26.4 As built, 2026-09-23
**Android-app** (`feat/rust-phase-11-notifications`, into `edge`):
- **`LinkedAccountRepository`** asks `/player/rust/links` on a `rust` site and
`/player/shard/accounts` on a `shard` site. A host that has never answered gets both asked, and a
host that named neither module gets neither.
- **`pushNeedsLink` and `canSetMode`** replace `itemSelectable`. The row stays live, the hint names
the push control, and a held push switch that is already on can still be turned off.
- **`PushContentResolver`** pulls the first inbox page under a five-second timeout. It titles only
from a row whose id matches the ref **and** whose trigger matches the tickle's stream. The
notification is `VISIBILITY_PRIVATE`, with a public version carrying the generic title.
`PushService` launches each tickle in its own job.
- **`resolveWebPath`** learns `/player/rust`, and `tab` on a Rust server path. `RustTab` gained its
wire names, and `RUST_SERVER` an optional `tab` argument.
The suite is **672 tests, 0 failures**, up from 657, with `lintDebug` and `assembleDebug` green.
**Module-Rust and core: no change**, as planned. This is the fifth Rust phase in a row that core did
not need to change for.
**The walk.** Real core on `main` with this module installed, the protocol-7 sidecar inside the Oxide
rig's container, the `s22_ultra` emulator, and `NTFY_PUBLIC_URL`/`NTFY_BASE_URL`/`NTFY_ALLOWED_ORIGINS`
all set to `https://ntfy.sh` (D72).
1. **Linked (raidowner1):** the raid alert's push switch was live. Switching it on registered
`https://ntfy.sh/<topic>` as the device's endpoint and stored the subscription. With no link
(walkadmin), push was held with the hint, while email and in-app stayed live. In-app was switched
off, stored as `off`, and switched back on.
2. **Raid, then the phone:** `rgraid.door` on the live rig, attacker `…09`, authorising raidowner1's
Steam id. Core dispatched to **two** channels (in-app and push). **Nineteen seconds** after the
console command, the phone posted *"Your base is being raided — A door was destroyed in P16 on
Oxide rig."* It was `vis=PRIVATE`, with a public version. The tap opened the inbox, and the row
opened the Oxide rig's server screen natively.
3. **Switched off on the phone:** push off in the app. The server then held no subscription and no
device, since nothing else had push on. The next raid dispatched to **one** channel. The inbox
row arrived (*"A door was destroyed in R15"*), no system notification was posted, and the push
service had stopped.
4. **D71:** rows seeded with the module's own URLs. `?tab=leaderboard` opened on the Leaderboard tab,
`/player/rust` opened *My Rust account*, and `?tab=clans` went to a Custom Tab.
**The criterion is met: a Rust notification arrived on a phone and was switched off there.**
### 26.5 What the walk found
Nothing the suite had missed. That's the first leg of this workstream to say so, and the reason is
worth keeping: every defect this phase fixed was found **by reading** before any code was written
(§26.1). The UO-shaped link check was visible in one line of the view model. The generic title was
visible in `PushStreams`. The cancelled pull was a property of `collectLatest` that nobody had needed
to think about until a handler suspended.
Two things are worth writing down:
- **The first real relay hop on any site went cleanly.** M7 built the distributor in 2026-07 and
nobody had delivered a tickle through a live ntfy to a device since. It worked the first time,
including registration against core's endpoint validator and the unregister when the last push
preference went off.
- **Two channels, then one, is the proof that "off" was honoured by the server, not by the phone.**
The app never saw the second raid's push: core did not enqueue it. A phone that merely stopped
showing notifications would have looked the same on the screen and different in the log.
### 26.6 What is not proven here
- **A UO site, walked.** No UO core was running. The UO branch is the same call it always was, and
`aUoSiteStillAsksTheShardModuleAndNeverTheRustOne` pins it.
- **The lock screen, seen.** The emulator has no screen lock. The public version was read back from
`dumpsys notification`, not looked at.
- **A burst.** Each tickle now runs in its own job, which is what a burst needs, but the walk sent
one raid at a time.
- **A real raid by a real player**, for §25.7's reason: the rig has one Steam account.
---
[aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app