From 045b637b0d05096e30d6cca7d99143ab18f36730 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 11 Nov 2026 04:11:38 -0800 Subject: [PATCH] feat(mobile): link to Builderlab web from Settings (#9300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither mobile app offers a way to reach Builderlab's web-only account features: GitHub linking, billing and creating a community. This adds a **Builderlab account** row to Settings that opens the build's Builderlab origin (app.builderlab.xyz on public builds) in the system browser. The row shows only on public builds. An internal build's Builderlab is its organization's tenant, where billing and the single community are managed by the organization, so the link wouldn't lead anywhere useful. Nostr-only builds have no Builderlab. Sign-in uses a non-ephemeral `ASWebAuthenticationSession`, which shares Safari's cookies, so Safari can reuse the Builderlab web session. A browser other than Safari, or Android, may still ask the user to sign in. | Before | After | | --- | --- | | before | after | Nothing else gets worse. The change adds one row and one browser launch on tap, and does no work at startup or in the background. A failure stays inside this row: if the browser doesn't open, a toast says so. ### Validation - Added tests: `builderlab_account_row_test.dart` checks that the row opens `HostedEnvironment.origin` and shows a toast when the browser fails to open (public build), and that the row is absent otherwise. It is added to the CI public-build lane. - `settings_page_test.dart`: the version-footer scroll check now finds the footer after it scrolls just offscreen. The extra row pushed it there under public-build defines. - Settings tests pass under both public and Nostr-only defines, and `flutter analyze` is clean. - iOS simulator (public build, signed in to the public Builderlab): the row appears below Connection, and tapping it opens Safari at Builderlab. This simulator's session came from an identity import, not the in-app browser sign-in, so Safari was signed out. I haven't yet verified that Safari arrives signed in after a real in-app sign-in. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: studio --- .github/workflows/_ci-clients.yml | 1 + .../lib/features/settings/settings_page.dart | 4 + .../builderlab_account_section.dart | 49 +++++++++++ .../settings/builderlab_account_row_test.dart | 85 +++++++++++++++++++ .../features/settings/settings_page_test.dart | 11 ++- 5 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 mobile/lib/features/settings/settings_page/builderlab_account_section.dart create mode 100644 mobile/test/features/settings/builderlab_account_row_test.dart diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index 442e66034b..d33a826251 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -163,6 +163,7 @@ jobs: test/features/agents/agents_restore_prompt_test.dart test/shared/hosted/relay_auth_isolation_test.dart test/features/onboarding/hosted_app_gate_environment_test.dart + test/features/settings/builderlab_account_row_test.dart test/features/onboarding/hosted_journeys_test.dart test/features/onboarding/onboarding_sign_out_test.dart test/features/onboarding/hosted_communities_test.dart diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 28da21583d..64750a2724 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -10,10 +10,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/shared/theme/buzz_icons.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../shared/auth/auth.dart'; import '../../shared/identity_transfer/nsec_export_sheet.dart'; import '../../shared/clipboard_utils.dart'; +import '../../shared/hosted/hosted_environment.dart'; import '../../shared/hosted/organization_environment.dart'; import '../../shared/hosted/organization_sign_out.dart'; import '../../shared/push/push_relay_capability_provider.dart'; @@ -33,6 +35,7 @@ part 'settings_page/profile_section.dart'; part 'settings_page/status_section.dart'; part 'settings_page/connection_section.dart'; part 'settings_page/notifications_section.dart'; +part 'settings_page/builderlab_account_section.dart'; Widget _emptyProfileEditPage(BuildContext context) => const SizedBox.shrink(); @@ -132,6 +135,7 @@ class SettingsPage extends HookConsumerWidget { _ConnectionSection( identityRecoveryPageBuilder: identityRecoveryPageBuilder, ), + if (HostedEnvironment.enabled) const _BuilderlabAccountSection(), // An internal build's only way out of its organization, rarely // needed, so it sits last. if (OrganizationEnvironment.enabled) diff --git a/mobile/lib/features/settings/settings_page/builderlab_account_section.dart b/mobile/lib/features/settings/settings_page/builderlab_account_section.dart new file mode 100644 index 0000000000..a08960a31e --- /dev/null +++ b/mobile/lib/features/settings/settings_page/builderlab_account_section.dart @@ -0,0 +1,49 @@ +part of '../settings_page.dart'; + +/// Opens a Builderlab web page outside the app. Overridable in tests. +final builderlabWebLauncherProvider = Provider Function(Uri)>( + (ref) => + (uri) => launchUrl(uri, mode: LaunchMode.externalApplication), +); + +/// Links a public build to its Builderlab web dashboard, for the account +/// features mobile doesn't have: GitHub linking, billing and creating +/// communities. Sign-in's web session is not ephemeral, so it shares +/// Safari's cookies and Safari can reuse that Builderlab web session. +class _BuilderlabAccountSection extends ConsumerWidget { + const _BuilderlabAccountSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return AppListCard( + verticalPadding: Grid.twelve, + children: [ + AppListRow( + key: const Key('settings-builderlab-account'), + title: 'Builderlab account', + trailing: Icon( + BuzzIcons.externalLink, + size: 18, + color: context.colors.onSurfaceVariant, + ), + onTap: () async { + bool opened; + try { + opened = await ref.read(builderlabWebLauncherProvider)( + HostedEnvironment.origin, + ); + } on PlatformException { + // launchUrl throws instead of returning false on some failures. + opened = false; + } + if (opened || !context.mounted) return; + ScaffoldMessenger.of(context).showFeedbackSnackBar( + const SnackBar(content: Text('Couldn’t open Builderlab.')), + kind: ToastKind.error, + ); + }, + ), + ], + ); + } +} diff --git a/mobile/test/features/settings/builderlab_account_row_test.dart b/mobile/test/features/settings/builderlab_account_row_test.dart new file mode 100644 index 0000000000..e4101d8346 --- /dev/null +++ b/mobile/test/features/settings/builderlab_account_row_test.dart @@ -0,0 +1,85 @@ +import 'package:buzz/features/settings/settings_page.dart'; +import 'package:buzz/shared/community/community_membership_provider.dart'; +import 'package:buzz/shared/hosted/hosted_environment.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + Future> pumpSettings( + WidgetTester tester, { + bool opens = true, + bool throws = false, + }) async { + PackageInfo.setMockInitialValues( + appName: 'Buzz', + packageName: 'xyz.block.buzz', + version: '0.16.0', + buildNumber: '432', + buildSignature: '', + ); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final opened = []; + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + currentCommunityRoleProvider.overrideWithValue( + const AsyncData(CommunityMemberRole.member), + ), + builderlabWebLauncherProvider.overrideWithValue((uri) async { + opened.add(uri); + if (throws) throw PlatformException(code: 'ACTIVITY_NOT_FOUND'); + return opens; + }), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: SettingsPage( + profileHeader: const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + return opened; + } + + final row = find.byKey(const Key('settings-builderlab-account')); + + testWidgets('opens the public Builderlab dashboard in the browser', ( + tester, + ) async { + final opened = await pumpSettings(tester); + await tester.scrollUntilVisible(row, 200); + await tester.tap(row); + await tester.pumpAndSettle(); + expect(opened, [HostedEnvironment.origin]); + expect(find.text('Couldn’t open Builderlab.'), findsNothing); + }, skip: !HostedEnvironment.enabled); + + for (final throws in [false, true]) { + testWidgets( + 'says so when the browser ${throws ? 'throws' : 'does not open'}', + (tester) async { + await pumpSettings(tester, opens: false, throws: throws); + await tester.scrollUntilVisible(row, 200); + await tester.tap(row); + await tester.pumpAndSettle(); + expect(find.text('Couldn’t open Builderlab.'), findsOneWidget); + }, + skip: !HostedEnvironment.enabled, + ); + } + + testWidgets('is absent outside a public build', (tester) async { + await pumpSettings(tester); + expect(row, findsNothing); + }, skip: HostedEnvironment.enabled); +} diff --git a/mobile/test/features/settings/settings_page_test.dart b/mobile/test/features/settings/settings_page_test.dart index 6f741d25ad..fa542092af 100644 --- a/mobile/test/features/settings/settings_page_test.dart +++ b/mobile/test/features/settings/settings_page_test.dart @@ -74,9 +74,8 @@ void main() { ); expect(find.text('Invite to community'), findsNothing); await tester.pumpAndSettle(); - final version = find.text( - buildNumber.isEmpty ? 'v0.16.0' : 'v0.16.0 ($buildNumber)', - ); + final label = buildNumber.isEmpty ? 'v0.16.0' : 'v0.16.0 ($buildNumber)'; + final version = find.text(label); await tester.scrollUntilVisible(version, 200); await tester.pumpAndSettle(); expect(version.hitTestable(), findsOneWidget); @@ -87,7 +86,11 @@ void main() { final beforeScroll = tester.getTopLeft(version).dy; await tester.drag(find.byType(ListView), const Offset(0, 100)); await tester.pumpAndSettle(); - expect(tester.getTopLeft(version).dy, greaterThan(beforeScroll)); + // Dragging down can push the footer just below the viewport. + expect( + tester.getTopLeft(find.text(label, skipOffstage: false)).dy, + greaterThan(beforeScroll), + ); expect(tester.takeException(), isNull); }); }