mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
A plain `cd mobile && flutter test` failed 11 push and notification tests on a clean checkout of main, while CI passed. Agents running the full suite kept reporting them as "pre-existing, also fails on main", which hides real regressions in the same files. ### Root cause Push is gated on the compile-time `BUZZ_PUSH_GATEWAY_URL` define (`Env.pushGatewayConfigured`, since [#7261 configurable gateway](https://github.com/block/buzz/pull/7261) made the gateway optional). Without it the app is a push-less build: `readBuzzPushEndpointGrants()` returns no grants and `buzzPushLifecycleEnabled()` is false. CI's mobile shards, `just mobile-test` and the lefthook pre-push lane (which runs `just mobile-test`) all pass `--dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example`; a bare `flutter test` passes nothing. Each of the 11 was checked against that: - `notification_toggle_test` (5 cases) and `push_lease_revocation_outbox_test` "hosted relay assertion": no endpoint grants without a gateway (`Bad state: No element` at the grant lookup, `No endpoint grant exists for push lease tombstone`). - `push_bridge_test` "reads and exposes persisted endpoint grants" and "debug enrollment carries the configured relay and gateway URLs": the bridge returns `[]` before calling native (`Expected: an object with length of <1>, Actual: []`). - `push_bootstrap_test` "default push requires relay capability" and "new community starts registration": the lifecycle is disabled, so registration never starts (`Expected <1>, Actual <0>`). - `push_bridge_test` "development push gateway matches the compiled configuration": a stale test. It expected a `https://push.buzz.xyz` default that [#7261 configurable gateway](https://github.com/block/buzz/pull/7261) removed from the app; under CI's define both sides equal `push.example`, so it could never fail there. The same files pass on the same Mac with the define, so nothing is environmental. The `Unexpected native call takePendingNotificationResponse` log is unrelated noise: the toggle test's native mock didn't answer that call, which logs (and is caught) in CI too. ### Fix Tests get the gateway from a test hook instead of the define. `Env.pushGatewayUrl` becomes a getter over the compiled value with a `@visibleForTesting` `debugPushGatewayUrlOverride` (the same pattern as `debugUriStreamOverride`), and `test/flutter_test_config.dart` sets it for every test file. `push_unconfigured_build_test` sets it to `''`, so it runs in the main suite instead of being skipped there and run separately. CI, the Justfile, `scripts/flake-check.mjs` and the AGENTS/README check commands drop the define, so CI, `just mobile-test`, pre-push and a bare `flutter test` run the same thing. I chose this over documenting a canonical command because the define can't be made automatic: `flutter test` has no config file for defines, so any doc fix still leaves the obvious command red. A test hook makes every invocation agree. The stale test is replaced by one in the push-less file that binds the production read: with the override cleared, `Env.pushGatewayUrl` must equal the compiled `BUZZ_PUSH_GATEWAY_URL`. Now that CI compiles without the define, it fails if the app regains a built-in gateway default, which the old test never could. The toggle test's mock answers `takePendingNotificationResponse` with no pending response. What gets better: one test mode everywhere, and the push-less tests run in every run. Nothing gets worse: production reads the same compiled value (the override is never set outside tests, and an `aot-snapshot` probe shows AOT still folds the getter to the compiled constant and drops push-only code when the define is absent), and every test still runs in the push-capable mode CI used before. ### Validation - On a fresh origin/main worktree with the Hermit toolchain, a bare `flutter test` failed exactly these 11; the same files passed on the same machine with the define. - After the fix, the pre-push `just mobile-check` and `just mobile-test` (now a bare `flutter test` plus the recipe and secp256k1 checks) pass, with the push-less tests running instead of skipped. `scripts/flake-check.test.mjs` passes. - Falsifiability: re-adding a `defaultValue` to the compiled gateway fails the new no-default test, and removing `flutter_test_config.dart` brings back the other 10 failures. - Unrelated: an earlier full run also failed `community_provider_test` "discard orphan snapshots and image caches", which waits 500 ms for a background directory delete. The Mac was running other full suites at the time; the file passed 8 of 8 runs alone, with and without the define. It is not touched here. Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
351 lines
16 KiB
Markdown
351 lines
16 KiB
Markdown
# Buzz Mobile
|
|
|
|
Flutter mobile client for Buzz.
|
|
|
|
See [VISION_MOBILE.md](../VISION_MOBILE.md) for intended behavior and
|
|
architecture.
|
|
|
|
## Builderlab builds
|
|
|
|
Three build-time settings, passed with Flutter's `--dart-define` (shell
|
|
environment variables alone are not read), choose the build's single
|
|
Builderlab. Set all three or none:
|
|
|
|
```bash
|
|
flutter run \
|
|
--dart-define=BUZZ_DISTRIBUTION=public \
|
|
--dart-define=BUZZ_BUILDERLAB_URL=https://app.builderlab.xyz \
|
|
--dart-define=BUZZ_BUILDERLAB_RELAYS=communities.buzz.xyz
|
|
```
|
|
|
|
- `BUZZ_DISTRIBUTION`: `public` or `internal`. See the
|
|
[public build](../CONTEXT.md#public-build) and
|
|
[internal build](../CONTEXT.md#internal-build), and
|
|
[ADR 0001](../docs/adr/0001-internal-and-public-mobile-builds.md).
|
|
- `BUZZ_BUILDERLAB_URL`: the build's Builderlab HTTPS origin. On iOS, sign-in
|
|
currently opens only Builderlab's own hosts (`app.builderlab.xyz`, or a
|
|
`<tenant>.builderlab.xyz` organization) and their staging equivalents; the
|
|
native browser bridge refuses any other origin.
|
|
- `BUZZ_BUILDERLAB_RELAYS`: comma-separated DNS domains whose relays use that
|
|
Builderlab's session. Entries match the domain itself and its subdomains,
|
|
case-insensitively. Use DNS names, not URLs or wildcards.
|
|
|
|
With none set, **Builderlab features are disabled**: no account login,
|
|
community creation, backup/recovery UI, reconnect prompts, or background account
|
|
restoration. QR/code pairing, community links and invites use Nostr-only entry.
|
|
Saved account keys and sessions are retained for a later enabled build.
|
|
|
|
A public build signs in with Builderlab's web account setup, and requires it for
|
|
communities under `BUZZ_BUILDERLAB_RELAYS`, which also places new communities
|
|
under the first domain. QR pairing and other communities stay available.
|
|
|
|
An internal build offers one **Sign in** action that goes to the organization's
|
|
single sign-on and then joins the community at its
|
|
`BUZZ_BUILDERLAB_RELAYS` host. It has no QR pairing, community switching or
|
|
community creation, refuses links to other relays, and hides communities on
|
|
other relays that an earlier build saved, keeping their keys.
|
|
|
|
An internal build takes exactly one `BUZZ_BUILDERLAB_RELAYS` entry, its
|
|
organization's relay host. It matches that exact host, not a domain, and the
|
|
relay always receives its Builderlab session's relay assertions.
|
|
|
|
## Agent creation links
|
|
|
|
`buzz://agent/new?card=<id>` opens the Agents page and an editable, pre-filled
|
|
Create agent form for the active community. Opening a link never creates an
|
|
agent. A Builderlab agent card's QR leads to its share page
|
|
(`app.builderlab.xyz/a/<id>`), whose **Add to Buzz** button opens this link.
|
|
|
|
The app fetches `<avatars CDN>/<id>/agent.json` from the CDN for this build's
|
|
Builderlab environment (`HostedEnvironment.avatarsCdnOrigin`), never from a
|
|
host named by the link. The link carries only the id, so the QR stays small
|
|
however long the instructions are.
|
|
|
|
```json
|
|
{
|
|
"id": "<id>",
|
|
"name": "Scout",
|
|
"avatarUrl": "https://<avatars CDN>/<id>/<revision>/avatar.png",
|
|
"instructions": "Reply concisely.",
|
|
"model": "<model catalog id>",
|
|
"reasoningEffort": "high"
|
|
}
|
|
```
|
|
|
|
- `id` (the link's card id), `name` and `avatarUrl` (an `https` URL) are
|
|
required. `instructions`, `model` and `reasoningEffort` are optional and left
|
|
for the user when absent. A missing required field or a known field of the
|
|
wrong type makes the card invalid, and no form opens. Other keys, including
|
|
`v` and `description`, are ignored whatever their type. The card id is opaque
|
|
to the app.
|
|
- Prefilled values are only a draft: Create applies its usual rules, so a name
|
|
outside 1 to 64 letters, numbers, spaces, dots, hyphens or underscores, or
|
|
instructions over 20,000 characters, must be edited before Create succeeds.
|
|
- The fetch shares the image loader's limits (`lib/shared/utils/bounded_fetch.dart`):
|
|
it is aborted after 20 seconds without a byte or 5 minutes overall, and a card
|
|
over 2 MB is rejected.
|
|
- `model` and `reasoningEffort` are pre-selected only if the live model catalog
|
|
offers them (the effort only for that model).
|
|
- The link waits for sign-in and community load. It needs Builderlab login and a
|
|
hosted community that supports remote agents. It reloads the signed-in
|
|
account's agents first, and is dropped with a message if an unfinished agent
|
|
setup exists (resume or discard that first) or the card can't be loaded.
|
|
- A link that arrives while Agents is open replaces it only when the list is
|
|
all that's showing. With a form, profile or dialog open, the link is dropped
|
|
with a message so unfinished work is kept.
|
|
|
|
The parser is `lib/shared/deeplink/agent_creation_link.dart`; the card reader
|
|
is `lib/features/agents/agent_creation_draft.dart`.
|
|
|
|
## Hosted relay authentication
|
|
|
|
`--dart-define=BUZZ_HOSTED_RELAY_AUTH=true` enables Builderlab relay assertions
|
|
and reconnect sign-in for a public build's `BUZZ_BUILDERLAB_RELAYS`. It
|
|
defaults off: configure backend assertion issuance before enabling mobile
|
|
authentication, and enable relay enforcement last.
|
|
|
|
## Setup
|
|
|
|
Use the Flutter SDK pinned by the repository. Activate Hermit from the repo
|
|
root before resolving packages or running any Flutter command:
|
|
|
|
```bash
|
|
cd /path/to/buzz
|
|
. ./bin/activate-hermit
|
|
./bin/just mobile-install
|
|
```
|
|
|
|
`mobile-build-android` intentionally builds with `--no-pub`. If an IDE or an
|
|
external Flutter SDK has touched `mobile/.dart_tool`, rerun `mobile-install`
|
|
with the pinned SDK before building so `flutter_test`, `sky_engine`, and the
|
|
engine all come from the same Flutter version.
|
|
|
|
### iOS toolchain
|
|
|
|
Use Xcode 27.1 for iOS development and build validation on an Apple silicon
|
|
Mac running macOS Tahoe 26.6 or later, as required by the
|
|
[Xcode 27 release notes](https://developer.apple.com/documentation/xcode-release-notes/xcode-27-release-notes).
|
|
On Intel Macs or hosts that cannot meet those requirements, use a compatible
|
|
Xcode 26 version, which remains supported. Activate the repository's pinned
|
|
Flutter SDK with Hermit as described above.
|
|
|
|
When multiple Xcode versions are installed, select the intended version for
|
|
the command with `DEVELOPER_DIR`, for example:
|
|
|
|
```bash
|
|
DEVELOPER_DIR=/Applications/Xcode-27.1.0.app/Contents/Developer xcodebuild -version
|
|
```
|
|
|
|
Adjust the path to your installation. Keep the compiler and SDK from the same
|
|
Xcode installation. The pinned Flutter native-assets tooling can discover the
|
|
globally selected SDK even when `DEVELOPER_DIR` selects another compiler, so
|
|
use a build machine with a matching default Xcode when validating either
|
|
Xcode 26 or Xcode 27.
|
|
|
|
The native cryptography dependency is pinned upstream C source in
|
|
`ios/BuzzPushKit/Sources/CSecp256k1`; it requires no Swift build-plugin approval.
|
|
GitHub's mobile CI currently uses the Xcode default of its `macos-latest`
|
|
runner. The internal release toolchain is configured separately in
|
|
[buzz-releases](https://github.com/squareup/buzz-releases).
|
|
|
|
## Run
|
|
|
|
```bash
|
|
# From repo root (applies a worktree-isolated debug identity and starts/reuses Simulator):
|
|
just mobile-dev
|
|
|
|
# Direct (uses the app's configured community; apply worktree overrides first):
|
|
cd mobile && flutter run --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example
|
|
```
|
|
|
|
### Worktree-aware debug identity
|
|
|
|
Debug builds produced from a git worktree get a unique app identifier keyed
|
|
to the **worktree directory name**
|
|
(`xyz.block.buzz.dev.mobile.<slug>` on iOS,
|
|
`xyz.block.buzz.mobile.<slug>` on Android) plus a display-only branch label
|
|
in the app name (`Buzz (my-branch)`, or a short SHA when the worktree is
|
|
detached). Because the identifier follows the directory rather than the
|
|
branch, one worktree keeps exactly one installed app — and its login state —
|
|
across branch switches, and builds from multiple worktrees install side by
|
|
side, mirroring the desktop dev experience. Release and profile builds
|
|
always keep the production identity and name.
|
|
|
|
`just mobile-dev` and `just mobile-build-android` apply this automatically by
|
|
running `scripts/mobile-worktree-overrides.sh`, which writes two gitignored
|
|
files:
|
|
|
|
- `mobile/ios/Flutter/WorktreeOverrides.xcconfig` (included by Debug builds
|
|
only; a developer's `AppOverrides.xcconfig` is included after it, so
|
|
app-specific overrides like a personal `BUNDLE_IDENTIFIER` for device
|
|
signing always win)
|
|
- `mobile/android/worktree.properties` (read by the debug build type only)
|
|
|
|
Android developers can keep a stable local test identity that takes precedence
|
|
over the generated worktree values by creating the gitignored
|
|
`mobile/android/AppOverrides.properties`:
|
|
|
|
```properties
|
|
appName=Buzz Pairing
|
|
applicationIdSuffix=.device_pairing_e2e1
|
|
```
|
|
|
|
These values are consumed by the debug build type only. The standard
|
|
`just mobile-build-android` command can still be used; regenerating
|
|
`worktree.properties` does not overwrite `AppOverrides.properties`. Release
|
|
and profile builds keep the production `Buzz` name and application ID.
|
|
|
|
For direct Xcode / Android Studio / `flutter run` development, run
|
|
`./scripts/mobile-worktree-overrides.sh` from the repo root once per branch
|
|
switch to refresh the display label (the install identity never changes);
|
|
the persisted files are then picked up by any subsequent build. In the main
|
|
checkout the script is a no-op that removes stale override files, restoring
|
|
the plain `Buzz` identity. To enable push in direct Xcode builds and Runner tests, supply a
|
|
`BUZZ_PUSH_GATEWAY_URL` build setting in the gitignored
|
|
`mobile/ios/Flutter/AppOverrides.xcconfig`; the build phase validates and
|
|
passes it through as a Flutter Dart define. Since `//` begins an xcconfig
|
|
comment, spell the origin as `BUZZ_PUSH_GATEWAY_URL = https:/$()/push.example`.
|
|
|
|
For an Android debug build that must remain installed alongside other Buzz
|
|
worktree builds, set an explicit launcher name and package suffix when invoking
|
|
the generator or a recipe that invokes it:
|
|
|
|
```bash
|
|
BUZZ_PUSH_GATEWAY_URL="https://push.example" \
|
|
BUZZ_ANDROID_DEBUG_APP_NAME="Buzz Huddles" \
|
|
BUZZ_ANDROID_DEBUG_ID_SUFFIX=".huddles_829c" \
|
|
./bin/just mobile-build-android
|
|
```
|
|
|
|
This example produces the debug-only package
|
|
`xyz.block.buzz.mobile.huddles_829c` with the launcher label `Buzz Huddles`.
|
|
The suffix must start with a dot followed by a lowercase letter and may contain
|
|
only lowercase letters, digits, and underscores. Release and profile builds
|
|
ignore these overrides and retain the production package and name.
|
|
|
|
To remove leftover worktree-suffixed installs from booted iOS simulators and
|
|
connected Android emulators, run `just mobile-clean` (add `--dry-run` via
|
|
`./scripts/mobile-worktree-clean.sh --dry-run` to preview). Production
|
|
installs are never touched.
|
|
|
|
### iOS push capability
|
|
|
|
Every iOS artifact builds and embeds the Notification Service Extension and
|
|
native push bridge. Runtime activation is fail-closed and scoped to the current
|
|
relay. After authenticated connectivity and a fully valid NIP-11 `buzz-push-v1` push
|
|
descriptor, Buzz independently requests display permission and registers with
|
|
APNs. Display denial or request failure does not gate the device token, gateway
|
|
enrollment, or lease publication, so a later user opt-in can display pushes
|
|
without rebuilding transport authority. An absent, malformed, or unreachable
|
|
descriptor leaves push inactive without partial enrollment.
|
|
|
|
Mobile builds without a gateway origin succeed with push unavailable. To enable
|
|
push, supply the gateway origin explicitly:
|
|
|
|
```bash
|
|
flutter build ios --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example
|
|
flutter build apk --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example
|
|
```
|
|
|
|
The iOS and Android build gates validate any supplied define, rejecting empty or
|
|
malformed values. Release/profile builds require an HTTPS origin without an
|
|
explicit port. An absent define disables permission requests, APNs registration,
|
|
gateway enrollment, and lease publication; Settings shows push as unavailable.
|
|
No production gateway is selected implicitly. Enrollment
|
|
grants and crash-recovery journals are scoped to this origin. Push has not
|
|
shipped to existing users, so there is no legacy-state or cross-gateway
|
|
migration. Changing gateways requires fresh enrollment; old installations
|
|
expire under their original gateway's lease policy. Current-gateway response
|
|
loss is still retried from the exact journaled request.
|
|
|
|
Relay rollout remains an explicit deployment opt-in. Only deployments with
|
|
`BUZZ_PUSH_ENABLED=true` advertise the descriptor and process push. See
|
|
`docs/push-gateway-deployment.md` for the canonical gateway profile contract,
|
|
manual physical-device proof, measurements, and rollback procedure.
|
|
|
|
For local physical-device development, override the identity and sandbox
|
|
environments in the gitignored `mobile/ios/Flutter/AppOverrides.xcconfig`:
|
|
|
|
```xcconfig
|
|
BUNDLE_IDENTIFIER = com.example.buzz.mobile
|
|
BUZZ_DEVELOPMENT_TEAM = YOUR_TEAM_ID
|
|
BUZZ_IOS_PUSH_ENVIRONMENT = development
|
|
BUZZ_APP_ATTEST_ENVIRONMENT = development
|
|
BUZZ_PUSH_GATEWAY_URL = https:/$()/push.example
|
|
```
|
|
|
|
Use your personal bundle ID and team above. Provision both the parent and its
|
|
`.NotificationService` extension. Configure an isolated gateway with the matching
|
|
App Attest application ID, APNs topic and sandbox certificate. Development
|
|
attestation requires the explicit `personal-dev-app-attest` gateway build feature
|
|
and `BUZZ_PUSH_APP_ATTEST_ENVIRONMENT=development`; ordinary gateway builds accept
|
|
production attestation only. See `docs/push-gateway-deployment.md`.
|
|
Validate distributed builds separately using a signed release candidate and
|
|
its matching gateway configuration.
|
|
|
|
Parent app identifiers require Apple's Communication
|
|
Notifications capability and a regenerated app provisioning profile. The
|
|
Notification Service Extension profile does not require that capability.
|
|
Enable it on the personal development App ID for local rich-presentation
|
|
validation. Enabling it on distribution App IDs is
|
|
a release follow-up and is not performed by this repository change. Without a
|
|
matching parent profile, source and unit validation still work, but the app
|
|
cannot be signed for a physical device.
|
|
|
|
APNs and the gateway continue to carry only the constant opaque wake-up. The
|
|
extension fetches the message from the scoped relay, verifies message, sender
|
|
profile, and channel-metadata signatures, and uses a bounded App Group cache
|
|
for names and app-rendered avatar thumbnails. It never fetches an avatar URL;
|
|
missing, stale, or invalid enrichment falls back to the verified message with a
|
|
short sender pubkey, community subtitle, and no image.
|
|
|
|
## Checks
|
|
|
|
```bash
|
|
dart format --output=none --set-exit-if-changed .
|
|
flutter analyze
|
|
flutter test
|
|
```
|
|
|
|
Or from the repo root: `just mobile-check` and `just mobile-test`.
|
|
|
|
When writing tests, favor a few journey tests that drive real user flows over
|
|
many small logic tests. See
|
|
[Testing Conventions](../AGENTS.md#testing-conventions).
|
|
|
|
## Android release signing
|
|
|
|
Android release builds fail unless all upload-key inputs are supplied through the
|
|
environment:
|
|
|
|
- `BUZZ_ANDROID_UPLOAD_KEYSTORE_PATH`: path to a CI-vended keystore file
|
|
- `BUZZ_ANDROID_UPLOAD_KEYSTORE_PASSWORD`
|
|
- `BUZZ_ANDROID_UPLOAD_KEY_ALIAS`
|
|
- `BUZZ_ANDROID_UPLOAD_KEY_PASSWORD`
|
|
|
|
The keystore path must be absolute, and the keystore must remain outside the
|
|
repository. Development and debug builds do not require these variables.
|
|
|
|
Release pipelines that sign through the central APK Signer service instead of
|
|
a local upload keystore must set `BUZZ_ANDROID_RELEASE_SIGNING=external`. That
|
|
mode produces an unsigned release bundle and refuses to run if any
|
|
`BUZZ_ANDROID_UPLOAD_*` value is also set.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
lib/
|
|
├── main.dart # Entry point, Riverpod bootstrap
|
|
├── app.dart # MaterialApp with theme
|
|
├── shared/
|
|
│ └── theme/ # Catppuccin light/dark, spacing tokens, extensions
|
|
└── features/
|
|
└── home/ # Placeholder home surface
|
|
```
|
|
|
|
- **State management:** Riverpod + Hooks (`HookConsumerWidget`)
|
|
- **Theme:** Catppuccin Latte (light) / Macchiato (dark) — matches desktop
|
|
- **Spacing:** `Grid` tokens for consistent spacing
|
|
- **Linting:** `flutter_lints` + `riverpod_lint` via `custom_lint`
|
|
- **Feature isolation:** No cross-feature imports except `shared/`
|