App relays get their own section, with add and remove #7

Merged
laoc merged 4 commits from feat/app-relays into main 2026-08-06 10:26:11 +00:00
Owner

Stacks on release/0.12.0 (#6) — merge that first.

What this fixes

The settings screen already showed relays in sensible groups. Underneath it offered one newline-separated textarea over the whole working set, and that box had two real defects, not just a dated look:

  • A delete did not stick. Remove a relay that came from your kind:10002 and seedRelaysFromNip65 re-merged it at the next login (merged = prefs.relays + discovered). It came back, and nothing on screen explained why. A delete that silently reverts reads as the app lying rather than as the relay being published by your own key.
  • A typo became a relay. There was no validation at all. hello went into the working set, where it could only ever fail to connect — and it failed as "not connected", which reads as the relay's fault rather than the address's.

What changed

AppRelays keeps the two sets apart and combines them only at the point of use (workingRelays), so the delete-comes-back regression is structurally impossible rather than merely fixed:

  • Your relay list — from your kind:10002. Shown and used, not edited here; editing it means republishing your relay list, which is a separate feature with a different blast radius.
  • App relays — Stash's defaults plus your own additions. A remove button per row, one add field, and refusals shown beside the box rather than swallowed.
  • Also queried — the two discovery aggregators, now their own read-only section instead of the tail of the editable one. A group mixing editable and non-editable rows cannot offer a remove button honestly.

A bare host gets wss://; an explicit ws:// is kept (a LAN or localhost relay legitimately has no TLS); https:// is refused rather than guessed at, because when the guess is wrong you get a relay that never connects and no hint we rewrote what you typed.

Existing installs migrate by subtracting the known NIP-65 list from the old merged blob. An unknown user list subtracts nothing — the safe direction, leaving you connected to exactly what you were connected to.

Verification

JVM unit suite      377 tests, 0 skipped, 0 failures
AppRelays           19 tests, 7/7 mutations caught
device suite        expected=56 started=56 passed=56 failed=0

Mutation rows each assert the patch landed via git diff --numstat first — two rows initially failed to apply and were reported as invalid rather than as a false green.

One thing worth reading

The first device run of this branch came back 21 of 56 red, all "relay not connected". I ran release/0.12.0 as a control and it went 55/55, which looked like a clean verdict against my change.

It was not. My red run had followed a hard pkill of a half-booted emulator; the control ran after a graceful shutdown. Two variables moved, not one. Re-running the same code with a clean emulator lifecycle gives 56/56, and the relay log tells the same story — 225 lines against 47 in the poisoned run, where it simply went deaf partway through with no error and only 3 connections.

So the red was the emulator, and a control that appeared to convict my code proved nothing because I had not held the environment fixed. Worth knowing before anyone reads a future red run here as a code defect.

Stacks on `release/0.12.0` (#6) — merge that first. ## What this fixes The settings screen already showed relays in sensible groups. Underneath it offered **one newline-separated textarea over the whole working set**, and that box had two real defects, not just a dated look: - **A delete did not stick.** Remove a relay that came from your `kind:10002` and `seedRelaysFromNip65` re-merged it at the next login (`merged = prefs.relays + discovered`). It came back, and nothing on screen explained why. A delete that silently reverts reads as the app lying rather than as the relay being published by your own key. - **A typo became a relay.** There was no validation at all. `hello` went into the working set, where it could only ever fail to connect — and it failed as "not connected", which reads as the relay's fault rather than the address's. ## What changed `AppRelays` keeps the two sets apart and combines them only at the point of use (`workingRelays`), so the delete-comes-back regression is structurally impossible rather than merely fixed: - **Your relay list** — from your `kind:10002`. Shown and used, not edited here; editing it means republishing your relay list, which is a separate feature with a different blast radius. - **App relays** — Stash's defaults plus your own additions. A remove button per row, one add field, and refusals shown beside the box rather than swallowed. - **Also queried** — the two discovery aggregators, now their own read-only section instead of the tail of the editable one. A group mixing editable and non-editable rows cannot offer a remove button honestly. A bare host gets `wss://`; an explicit `ws://` is kept (a LAN or localhost relay legitimately has no TLS); `https://` is refused rather than guessed at, because when the guess is wrong you get a relay that never connects and no hint we rewrote what you typed. Existing installs migrate by subtracting the known NIP-65 list from the old merged blob. An unknown user list subtracts nothing — the safe direction, leaving you connected to exactly what you were connected to. ## Verification ``` JVM unit suite 377 tests, 0 skipped, 0 failures AppRelays 19 tests, 7/7 mutations caught device suite expected=56 started=56 passed=56 failed=0 ``` Mutation rows each assert the patch landed via `git diff --numstat` first — two rows initially failed to apply and were reported as invalid rather than as a false green. ## One thing worth reading The first device run of this branch came back **21 of 56 red**, all "relay not connected". I ran `release/0.12.0` as a control and it went 55/55, which looked like a clean verdict against my change. **It was not.** My red run had followed a hard `pkill` of a half-booted emulator; the control ran after a graceful shutdown. Two variables moved, not one. Re-running **the same code** with a clean emulator lifecycle gives 56/56, and the relay log tells the same story — 225 lines against 47 in the poisoned run, where it simply went deaf partway through with no error and only 3 connections. So the red was the emulator, and a control that appeared to convict my code proved nothing because I had not held the environment fixed. Worth knowing before anyone reads a future red run here as a code defect.
The settings box edited one flat list that had the user's kind:10002
relays merged into it, so deleting one appeared to work and then
seedRelaysFromNip65 re-added it at the next login. A delete that
silently comes back reads as the app lying rather than as the relay
being published by the user's own key.

AppRelays keeps the two sets apart and combines them only at the point
of use, which makes that regression structurally impossible rather than
merely fixed. It also stops a typo becoming a relay: the old textarea
accepted "hello" into the working set.
Give app relays their own section, with add and remove
All checks were successful
build / build (push) Successful in 7m17s
fb0b5e808f
The settings screen showed three sensible groups and then offered one
newline-separated textarea over the whole working set to edit them. That
box is where the delete-comes-back bug lived, and it also accepted
anything: "hello" went into the working set as a relay, where it could
only fail to connect, and it failed as "not connected" — which reads as
the relay's fault rather than the address's.

Now: the user's published list is shown and not edited here, app relays
get a remove button each and a single add field, and the discovery pair
is its own read-only section rather than the tail of the editable one.
A refused address stays in the box beside the reason.

Removing a relay writes through to app_relays and the NIP-65 seed no
longer merges into it, so a delete is permanent.
Bump versionCode so the app-relays test build upgrades
All checks were successful
build / build (push) Successful in 6m36s
ae40535d2b
The 0.12.0 debug APK already on laoc's phone is versionCode 15, and
Android refuses a sideload at an equal code. versionName stays 0.12.0 —
this is more of the same release, not a new one.
Discover the user's relay list on a bunker login too
All checks were successful
build / build (push) Successful in 7m3s
81f139ea8f
Reported from a real phone: after signing in with a bunker code, "Your
relay list" stayed empty while the four relays from that account's
kind:10002 sat under App relays labelled "added by you" — relays the
user had never typed.

One cause, two symptoms. Of the three ways in, only this one never
called seedRelaysFromNip65: Amber does, the init path does, and the
bunker branch went straight from "connected" to refresh(). Nothing
populated the NIP-65 set, and both halves of the settings screen are
derived from it — sections() decides the label by membership of that
set, and appRelays is the pre-split blob minus that set. An empty set
matches nothing and subtracts nothing, so every relay the user
published is re-described as one they added by hand.

Worse than a wrong label: it is a wrong label the user can act on. The
row offers a remove button for a list that is not theirs to edit, and
editing anything there persists the wrong split.

The test drives the ViewModel rather than the login screen. The pairing
itself already has a test against a real nak bunker; what was unproven
is whether the login PATH seeds, and three attempts at asserting it
through the screen spent their time on the button rather than the
defect. Joining the login job covers the discovery it starts, because
the seed is an async child of that same coroutine — and if it is never
started the join returns just as cleanly, which is exactly what the
red run showed.

Red first: login completed, identity correct, nip65Relays empty.
58/58 on the device.
Author
Owner

Confirmed and fixed here — reported by @laoc42 from a real phone, 81f139e.

After signing in with a bunker:// code, "Your relay list" stayed empty while the four relays from that account's kind:10002 sat under App relays, labelled added by you.

One cause, both halves

Of the three ways in, only the bunker path never called seedRelaysFromNip65. Amber's login() does, init does, and the BunkerConnectResult.Connected branch went straight from connected to refresh().

Everything the settings screen says about provenance is derived from that one set:

  • RelayInventory.sections decides the label purely by membership of fromUserList
  • appRelays is the pre-split blob minus that same set

An empty set matches nothing and subtracts nothing, so every relay the user published is re-described as one they typed. That is not just cosmetic — the App relays section carries a remove button, and editing anything there persists the wrong split.

The test

Red first: the login completed with the correct identity and nip65Relays empty.

It drives the ViewModel, not the login screen. Three attempts through the screen failed on the button rather than on the defect — the soft keyboard covering it, a non-scrolling column, coordinate-based performClick. The pairing already has a test against a real nak bunker (BunkerSignerE2ETest), so asserting it through the UI again bought nothing.

Joining the login job covers the discovery it starts, because the seed is an async child of that same coroutine — and if it is never started the join returns just as cleanly, which is exactly what the red run showed.

device   58/58 at 81f139e

Fixture note: the relay list under test is signed by the bunker's own key and shaped like the real one behind the report — unmarked entries plus one explicit write, with the trailing slashes a published list actually carries.

**Confirmed and fixed here** — reported by @laoc42 from a real phone, `81f139e`. After signing in with a `bunker://` code, "Your relay list" stayed empty while the four relays from that account's `kind:10002` sat under **App relays**, labelled *added by you*. ## One cause, both halves Of the three ways in, **only the bunker path never called `seedRelaysFromNip65`**. Amber's `login()` does, `init` does, and the `BunkerConnectResult.Connected` branch went straight from connected to `refresh()`. Everything the settings screen says about provenance is derived from that one set: - `RelayInventory.sections` decides the label purely by membership of `fromUserList` - `appRelays` is the pre-split blob **minus** that same set An empty set matches nothing and subtracts nothing, so every relay the user published is re-described as one they typed. That is not just cosmetic — the App relays section carries a remove button, and editing anything there **persists the wrong split**. ## The test Red first: the login completed with the correct identity and `nip65Relays` empty. It drives the **ViewModel**, not the login screen. Three attempts through the screen failed on the button rather than on the defect — the soft keyboard covering it, a non-scrolling column, coordinate-based `performClick`. The pairing already has a test against a real `nak bunker` (`BunkerSignerE2ETest`), so asserting it through the UI again bought nothing. Joining the login job covers the discovery it starts, because the seed is an `async` child of that same coroutine — **and if it is never started the join returns just as cleanly**, which is exactly what the red run showed. ``` device 58/58 at 81f139e ``` Fixture note: the relay list under test is signed by the bunker's own key and shaped like the real one behind the report — unmarked entries plus one explicit `write`, with the trailing slashes a published list actually carries.
laoc changed target branch from release/0.12.0 to main 2026-08-06 10:26:02 +00:00
laoc merged commit 90cc93a88c into main 2026-08-06 10:26:11 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
laoc/stash-bookmark!7
No description provided.