Commit Graph

4 Commits

Author SHA1 Message Date
Tyler
dafe31bfe5 feat(mobile): persist frontier-only NIP-RS read state across devices and restarts (#8998) 2026-11-03 18:10:28 -05:00
Tom Brow
770ab7ff82 perf(mobile): decode relay query responses off the UI isolate (#8669)
### Context

Profiling on an iPhone 12 mini (Profile build, real community) showed
`RelaySessionNotifier._postForEvents` producing contiguous main-isolate
busy runs with p90 110 ms and max 124 ms (27 runs of 50 ms or longer).
Stacks were dominated by `jsonDecode` of `response.body`, with
`NostrEvent.fromJson` smaller. The whole HTTP response decode ran on the
UI isolate: gzip inflate (done by `dart:io`), the UTF-8 decode behind
`response.body`, JSON parsing, and event parsing.

### What changes

- Successful `/query` and `/profiles/search` responses (the only two
callers of `_postForEvents`) are now decoded in a single `Isolate.run`
from `response.bodyBytes`: inflate, UTF-8, JSON, validation,
`NostrEvent.fromJson`. The parsed events come back by ownership transfer
(`Isolate.exit`), not a copy.
- Gzip: the query transport now sends `Accept-Encoding: gzip` and sets
`autoUncompress = false`, so compressed bodies reach the isolate still
compressed. Whether bytes are gzip is decided by the gzip magic bytes,
so a transport that already inflated (or an injected test client) still
works. Error bodies are inflated inline (small).
- Unchanged: `FormatException` messages, the malformed-event wrapping,
`RelayException.body`, and the rate-limit gate reading the error body.
- Fencing (Review-Proven Rule 2): the `_disposed` / `_ageRestricted` /
`_contextGeneration` check still runs before the decode and now runs
again after it. If the session retires during the decode, the query
reports `StateError` even when the decode itself failed, matching the
old ordering where a retired session never decoded.

### Design notes

- `Isolate.run` per response instead of a long-lived worker: no
lifecycle or disposal to manage, no idle isolate heap on a small phone,
easy to fake in tests, and the same shape as `parseProfileEventBatch`.
The main-isolate side of a spawn measured at microseconds, so there is
no size threshold; small responses take the same path.
- Event id and signature verification does not happen on these events on
the main isolate today (nothing in the mobile app verifies queried
events; NIP-OA verification in profile parsing is already in an
isolate), so nothing was moved.
- Other HTTP decode sites found and left alone: invite create/join,
hosted join policy, BuilderLab account, media upload response, community
icon lookup, dev push lease NIP-11, archived-agents. These decode small,
rare responses (one small JSON object each), where an isolate adds
complexity without a measurable gain. Media and avatar byte downloads do
no JSON decoding. The mobile app has no `/count` caller.

### Validation

Added tests (`relay_session_test.dart`, through the production
`queryRelay` / `searchProfiles` path with the real isolate decoder):
parsing with non-ASCII content; profile search; gzip success body and
`Accept-Encoding`; gzip error body feeding the rate-limit gate and
`RelayException.body`; malformed JSON; non-list body; non-object
element; malformed event; invalid UTF-8; error status skips decoding;
session disposed, age-restricted, or retired-before-decode while a
decode is outstanding.

The fencing tests are falsifiable: removing the post-decode check fails
the disposed and age-restricted tests; removing the
retired-wins-over-decode-error branch fails its test; removing the
pre-decode check fails the never-decodes test.

`FakeRelay` (test helper) now injects an inline decoder, because
`fakeAsync` cannot deliver isolate results; it calls the same parse
function.

The default transport still honors `http.runWithClient`, which the
hosted journey tests use to fake the network
(`relay_http_query_client_test.dart`; fails without the fix).

`just mobile-check` and `just mobile-test` pass.

Host benchmark (AOT executable on an Apple silicon Mac, median of 25,
copy of the parse logic, main-isolate stall measured with a 0.5 ms
heartbeat timer):

| Response | Main-isolate blocked before | after |
| --- | --- | --- |
| 500 events, ~630 KB | 2.4 ms | 0 ms (max gap 0.05 ms) |
| 500 events, ~630 KB gzip | 3.2 ms | 0 ms (max gap 0.19 ms) |
| 500 events with 50 20 KB avatars, ~1.6 MB | 5.1 ms | 0 ms (max gap
0.28 ms) |
| 500 events, ~4.5 MB | 14.0 ms | 0 ms (max gap 0.27 ms) |
| 500 events, ~4.5 MB (827 KB gzip) | 17.4 ms | 0 ms (max gap 0.35 ms) |

Total latency is unchanged within noise (for example 14.5 ms to 14.9
ms). The host is much faster than the 12 mini, so absolute device
savings should be several times larger.

On the iPhone 12 mini (Profile builds of base and this branch, real
community over the production relay, two runs per scenario; main-isolate
stalls measured with a 4 ms heartbeat timer, since the wireless device
tunnel has no VM service):

| Scenario | Main-isolate `_postForEvents` time, base | this branch |
Max stall, base | this branch |
| --- | --- | --- | --- | --- |
| Cold start + 60 s (~16k events, 7.8 MB gzip) | 282 to 295 ms (max 116
ms) | 0.01 ms | 147 to 150 ms | 100 to 102 ms |
| Inbox + search (~7k events) | 122 to 123 ms (max 111 ms) | 0.01 ms |
138 to 142 ms | 97 to 99 ms |
| Open 6 channels (558 events) | 5 to 6 ms | 0.01 ms | 88 ms | 86 to 87
ms |
| Background 10 s + resume (~7k events) | 118 to 124 ms | 0.01 ms | n/a
(suspension) | n/a |

- The production relay serves gzip (about 4.4:1; the largest catch-up
response is 3.15 MB on the wire, 13.9 MB inflated, 7k events). Inside
the isolate that response takes 30 to 47 ms to inflate, 39 to 55 ms for
UTF-8, 65 to 105 ms for `jsonDecode` and 7 to 32 ms for
`NostrEvent.fromJson`.
- Many 16 to 90 ms stalls remain on both builds; they come from other
work (mostly markdown build and layout), not response decoding.
- Home, channels, inbox, search, profile and a thread render the same on
both builds.

buzz-review-completed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 08:44:52 -07:00
Wes
b6aa5539e3 feat(mobile): account onboarding, key recovery and hosted reconnect (#8375)
## Behavior

Adds optional mobile Builderlab sign-in, identity backup/recovery,
community discovery/creation, and account-aware reconnect. **Builderlab
features are disabled by default:** when `BUZZ_AUTH_DOMAINS` is unset or
empty, account login, creation, backup/recovery, reconnect prompts, and
background account restoration are hidden/disabled. QR/code pairing,
links, and invites retain Nostr-only entry. Saved account state is
retained for later opt-in.


`--dart-define=BUZZ_AUTH_DOMAINS=communities.buzz.xyz,communities.public.builderlabstaging.xyz`
opts those DNS domains and their subdomains into required account setup.
The same policy gates pairing, deep links, durable community admission,
and reconnect. A nonempty list enables Builderlab features;
`BUZZ_BUILDERLAB_URL` alone does not. Enabled flows still require their
backend services. Existing Nostr relay authentication remains mandatory.

`BUZZ_HOSTED_RELAY_AUTH=true` enables scoped relay assertions and
reconnect prompts only for configured authentication domains. Configure
assertion issuance and each community's issuer binding before enabling
assertions; enable relay enforcement last. Account credentials stay at
Builderlab. Wrong-account recovery preserves keys; failed writes are not
replayed; authentication is bounded by request deadlines.

Hosted flows preserve keys across cancellation/restart, reconcile
interrupted creation/invitation acceptance, and support manual nsec file
import/export. Account identities are scoped to backend and
authenticated subject; reusing an existing local key for an unbound
account requires an explicit selection. Recovery reconciles the
advertised and recovered inventories before automatic selection.
Successful self-hosted admission retires its pending key, and iOS
startup removes interrupted export files. Adds authenticated relay `GET
/operator/communities/joined` for the companion Builderlab membership
endpoint.

## Validation

- Follow-up review repair at `66a12ac92`: self-hosted entry presents
readable choices and import despite incomplete enumeration, while
generation still requires a complete empty inventory; explicit selection
uses exact-key lookup. Consuming a value-equal duplicate now advances
dispatch. Reconnect requires an existing account binding or a
subject-scoped backup receipt before signing an assertion, and pins the
subject inside the serialized account request. An empty different
account is refused without sending key proof. Focused checks: configured
domains 58 passed; default 49 passed, 9 hosted-only skips. Separate
isolated mutations fail the incomplete-inventory sheet, exact-key reuse,
duplicate dispatch and reconnect journeys (including an
empty-account-only bypass); restored controls pass. Evidence uses
synthetic storage/network boundaries, not physical Android or live
backend association. All mandatory commit/push hooks passed; fresh
GitHub CI is pending.

- Bounded review repair at `d3cc0003f`: incomplete secure-storage
enumeration now blocks automatic identity generation while explicit
known-key reads retain their fallback; declined/failed onboarding links
release the queue; HTTP 429/5xx keeps Retry regardless of response
encoding. Default app-gate coverage exercises a bind link followed by
ordinary community entry. Focused checks: configured domains 47 passed;
default 38 passed, 9 hosted-only skips. Removing the three production
fixes in an isolated source/Git/build copy fails the
identity-resolution, declined/failed queue, and Retry journey
assertions; clean and restored controls pass. Storage and HTTP failures
are synthetic; physical Android and live outage behavior were not
exercised. Published after all mandatory commit/push hooks passed
(mobile analysis/tests, branch-skew and file-size gates); fresh GitHub
CI remains pending.

- Existing whole-PR source review cleared the reconnect defects at
`fb15d5b82`.
- Domain-policy increment: focused environment, entry, app-gate, pairing
and onboarding journeys pass with empty and configured domains. Existing
pairing provider/page tests pass. Changed Dart files analyze cleanly.
- Isolated source/Git/build mutation: forcing paired credentials through
hosted login fails the default-build journey; clean and restored
controls pass. CI now runs configured-domain journeys in addition to the
default-build suite.
- Integrated main through `5b433d929` at `0067c069c`, including its
pinned Flutter upgrade. Preserved the PR welcome UI with the updated
transition API and added Material surfaces around onboarding
selection/consent rows. Focused post-merge runs pass in both
configurations; all mandatory local push gates passed (mobile
analysis/tests, Rust tests, desktop-native checks, branch-skew and
file-size checks).

- Merge-review repair at `a315bce11`: focused identity/storage/admission
and widget journeys pass, including account switching with an empty or
already-bound second account, explicit reuse, and self-hosted
removal/rejoin with another key. Default-domain journeys pass too
(hosted-only cases intentionally skip there).
- Isolated source/Git/build mutation checks: bypassing account scoping,
reuse consent, pending-key retirement, or recovery inventory
reconciliation fails the relevant journey/invariant assertions; clean
and restored controls pass.
- Native `RunnerTests/IdentityFilePluginTests`: both tests pass on the
iPhone 18 Pro iOS 27 simulator with an isolated app identifier. At
`24a0dcaac`, the picker journey seeds residue in Library/Caches,
matching path_provider_foundation, and verifies plugin startup removes
it while preserving an unrelated file. The earlier tmp-only fixture did
not exercise the actual export location. This is synthetic native
cleanup/picker evidence, not a real terminated-share or live account
round trip.

- Follow-up: Caches and tmp cleanup; fallback to individually known
identity slots when Android bulk enumeration fails; explicit replacement
of a refused self-hosted key preserves its original before swapping the
retry slot. Configured-domain journeys/invariants: 34 passed; default:
31 passed, 3 hosted-only skips. Isolated enumeration/retry mutations
fail with passing controls. Bulk-read fallback preserves but cannot
enumerate arbitrary other-account slots until bulk reads recover.

- Configured-domain CI exposed a stale pairing-status assertion after
account scoping. The repaired test checks the admission mismatch,
preserved account/transfer keys, and no admitted community. The
configured-domain CI selection passes locally (56 tests); removing the
production mismatch guard fails the updated test, with clean/restored
controls passing. This is synthetic coordinator evidence, not physical
QR acceptance.

- Final review repair (`40e1e6101`): native iOS export creation uses
atomic complete file protection; normal and restart cleanup remain.
Account/policy/claim HTTP uses one 30-second total deadline with request
abort and body cancellation. Removed the unrelated NIP-FA spec edit.
- Focused account/admission/export tests: 25 pass; configured-domain
account/admission/onboarding journeys: 33 pass. Isolated deadline and
native-dispatch mutations fail, with passing clean/restored controls.
Native export/picker tests: 2 pass on iOS 27 simulator. The real
file-protection attribute assertion is physical-device-only because
simulator provides no Data Protection attribute; locked-device
protection and interrupted real sharing have not been observed.

- Opt-in UI: configured-domain CI selection passes (57 tests); default
selection passes (7 tests, 10 hosted-only skips); URL-only plus
whitespace/empty domains passes (6 tests, 9 hosted-only skips). Changed
Dart files analyze cleanly. Independent mutations that unconditionally
initialize the coordinator in the app gate, welcome entry, or link form
fail the default journey; clean/restored controls pass.
- Native welcome screens exercised on iPhone 18 Pro / iOS 27 simulator
in default and configured builds: default exposes community link / QR /
pairing code only; configured build additionally exposes Builderlab
connection and community creation. No live hosted login was exercised in
this check.

- Delivery at `e48fe73a9`: all mandatory push hooks passed (full mobile
analysis/tests, Rust tests, desktop-native checks, branch and file-size
checks). GitHub checks had not yet populated at the immediate post-push
snapshot.

## Review disposition

| Feedback | Disposition |
| --- | --- |
| Explicit self-hosted entry under unreadable inventory, duplicate
invite dispatch, reconnect cross-account binding
([entry](https://github.com/block/buzz/pull/8375#discussion_r4318421622),
[queue](https://github.com/block/buzz/pull/8375#discussion_r4318421640),
[reconnect](https://github.com/block/buzz/pull/8375#discussion_r4318455512))
| Repaired at `66a12ac92`; evidence above. Reconnect refuses unproven
ownership rather than silently establishing a binding. |
| Incomplete Android identity inventory, stranded deep-link queue,
transient HTTP retry classification
([review](https://github.com/block/buzz/pull/8375#pullrequestreview-5576864143))
| All three repaired in `d3cc0003f`; focused journeys and isolated
mutation evidence above. |
| Account cross-link, incomplete recovery inventory, committed invite
checkpoint, pending local key retirement/replacement | Repaired in the
preceding commits; journey and mutation evidence above. |
| iOS interrupted key export
([review](https://github.com/block/buzz/pull/8375#pullrequestreview-5575253252))
| Native cleanup plus complete protection at creation implemented.
Physical-device lock and real interrupted sharing acceptance remains
pending. |
| Trickle responses bypass idle timeout; unrelated NIP-FA edit
([review](https://github.com/block/buzz/pull/8375#pullrequestreview-5575764556))
| Total request deadline/abort implemented; spec edit removed. |
| Production domains rejected | Ordinary entry now needs no Builderlab
configuration. Opt-in account-required domains must still use supported
community mappings. |
| Lost Android Keystore recovery | Backup exclusions prevent the
reviewed stale-restore scenario; genuine key loss still has no in-app
recovery/reset and remains deferred. |
| Native secp256k1 dependency question | Obsolete against the current
base: upstream C integration landed in #8398; no package diff remains
here. |
| Operator membership endpoint contract | Documentation now states the
operator chooses the pubkey; no end-user session binding is implied.
Additional handler-level coverage is optional follow-up. |
| Optional Android partial document cleanup/activity retention; local
work after backup cancellation | Follow-up hardening, not claimed
implemented. Cancellation prevents remote backup upload. |
| Semgrep crypto inventory notices | Informational inventory, not
demonstrated defects. |

## Remaining draft gates

Native QR/browser-return acceptance remains unverified for this
revision. Optional Builderlab onboarding still needs backend
backup-status and joined-community routes, recovery
policy/configuration, and real backup/recovery/reconnect acceptance. The
empty-domain build removes these services as prerequisites for ordinary
pairing; it does not make the optional hosted flows backend-ready. Human
acceptance and current CI are still required before merge.



Review dispositions retained: production auth-domain configuration must
use supported community mappings; enabling `builderlab.xyz` does not add
support for `buzz.block.builderlab.xyz`. Recovery after genuinely losing
an Android Keystore key remains a follow-up, not a newly implemented
capability.


Human acceptance steps: build once without `BUZZ_AUTH_DOMAINS`
(including with a previously saved account) and confirm no Builderlab
controls/restoration while ordinary link and QR/code entry work. Build
with supported domains and confirm connection/creation appear; exercise
browser return and live backup/recovery/reconnect. On physical iOS,
exercise export and interrupted sharing/locked-device protection. Record
results before marking review complete.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@wes.communities.public.builderlabstaging.xyz>
2026-10-19 15:46:58 -06:00
klopez4212
b30f1f6129 Polish mobile profiles, DMs, and sheets (#5401)
## Summary
- add poster-first, tap-to-toggle animated avatars on profile surfaces
while preserving transparent/static behavior elsewhere
- align mobile DM headers, membership actions, and invisible agent
recipient addressing with established desktop semantics
- polish titled sheets and status editing, preserve native iOS sheet
corners, and batch relay reads to improve review-build responsiveness

## Snapshots

<table>
  <tr>
    <th>Profile avatar</th>
    <th>Agent DM header and composer</th>
  </tr>
  <tr>
<td><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--profile-avatar.png"
width="360" alt="Mobile profile settings with animated avatar
surface"></td>
<td><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--agent-dm.png"
width="360" alt="Agent direct message with masked presence and normal
composer"></td>
  </tr>
  <tr>
    <th>Members sheet</th>
    <th>Status editor</th>
  </tr>
  <tr>
<td><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--members-sheet.png"
width="360" alt="Members bottom sheet with centered title and padded
content"></td>
<td><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--status-sheet.png"
width="360" alt="Status editor bottom sheet with duration and quick
statuses"></td>
  </tr>
  <tr>
    <th colspan="2">Switch Community</th>
  </tr>
  <tr>
<td colspan="2" align="center"><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--switch-community.png"
width="720" alt="Switch Community bottom sheet with centered title and
aligned Edit action"></td>
  </tr>
</table>

## Validation
- `just mobile-check`
- `just mobile-test` (1,283 tests)
- installed and reviewed isolated debug builds on iPhone and Pixel

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-13 20:16:04 -07:00