diff --git a/mobile/lib/features/onboarding/hosted_community_selection.dart b/mobile/lib/features/onboarding/hosted_community_selection.dart new file mode 100644 index 0000000000..7d906a5c77 --- /dev/null +++ b/mobile/lib/features/onboarding/hosted_community_selection.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/deeplink/pending_deep_link_provider.dart'; +import '../../shared/hosted/hosted_flow.dart'; +import '../../shared/theme/grid.dart'; +import 'community_link_input.dart'; + +/// Public community choices only; identity material remains in secure storage. +class HostedCommunitySelection extends HookConsumerWidget { + const HostedCommunitySelection({super.key, required this.flow}); + final HostedFlow flow; + + @override + Widget build(BuildContext context, WidgetRef ref) { + useListenable(flow); + final invite = useTextEditingController(); + final error = useState(null); + if (flow.communities.isNotEmpty) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final community in flow.communities) + CheckboxListTile( + key: ValueKey(community.relayUrl), + title: Text(community.name), + subtitle: Text(Uri.parse(community.relayUrl).host), + value: flow.selectedRelays.contains(community.relayUrl), + onChanged: (value) => + flow.selectCommunity(community.relayUrl, value ?? false), + ), + const SizedBox(height: Grid.sm), + FilledButton( + onPressed: flow.selectedRelays.isEmpty + ? null + : flow.addSelectedCommunities, + child: const Text('Continue'), + ), + ], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton( + onPressed: flow.openCommunityCreation, + child: const Text('Create a community in Builderlab'), + ), + TextButton( + onPressed: flow.refreshCommunities, + child: const Text('Refresh communities'), + ), + const SizedBox(height: Grid.sm), + TextField( + controller: invite, + keyboardType: TextInputType.url, + autocorrect: false, + enableSuggestions: false, + decoration: InputDecoration( + labelText: 'Community address or invite link', + errorText: error.value, + ), + ), + OutlinedButton( + onPressed: () async { + final uri = parseCommunityLinkInput(invite.text); + if (uri == null) { + error.value = 'Enter a valid community address or invite link.'; + return; + } + // Hand off to the existing dispatcher, including self-hosted invites. + final pending = ref.read(pendingDeepLinkProvider.notifier); + await flow.leave(); + if (flow.stage == HostedFlowStage.idle) pending.open(uri); + }, + child: const Text('Open community link'), + ), + ], + ); + } +} diff --git a/mobile/lib/features/onboarding/hosted_connect_page.dart b/mobile/lib/features/onboarding/hosted_connect_page.dart index fbde88df64..c74c50008c 100644 --- a/mobile/lib/features/onboarding/hosted_connect_page.dart +++ b/mobile/lib/features/onboarding/hosted_connect_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/hosted/hosted_flow.dart'; +import 'hosted_community_selection.dart'; import '../../shared/hosted/hosted_flow_problem_message.dart'; import '../../shared/utils/string_utils.dart'; import '../../shared/theme/grid.dart'; @@ -24,7 +25,14 @@ class HostedConnectPage extends HookConsumerWidget { final working = flow.stage == HostedFlowStage.working; final message = switch (flow.stage) { HostedFlowStage.idle => 'Continue connecting your account.', - HostedFlowStage.working => 'Connecting your account…', + HostedFlowStage.working => + flow.addingCommunityName == null + ? 'Securing your account and finding communities…' + : 'Adding ${flow.addingCommunityName}…', + HostedFlowStage.chooseCommunities => + flow.communities.isEmpty + ? 'No active owned communities yet. Create one in Builderlab, then refresh, or enter a community link.' + : 'Choose your communities', HostedFlowStage.chooseIdentity => flow.identityChoices.any((choice) => choice.fromCloud) ? 'Use your Builderlab identity on this device? It differs from your saved community identities. Those keys and your cloud backups will be kept. This app cannot change the selected Builderlab identity later.' @@ -77,10 +85,19 @@ class HostedConnectPage extends HookConsumerWidget { ), ), ), + if (flow.stage == HostedFlowStage.chooseCommunities) + HostedCommunitySelection(flow: flow), + if (flow.stage == HostedFlowStage.blocked && + flow.addedCommunityCount > 0) + Text( + '${flow.addedCommunityCount} communities added. ' + 'They have been kept; retry continues the remaining additions.', + ), if (working) const Center(child: CircularProgressIndicator()), if (!working && flow.stage != HostedFlowStage.chooseIdentity && + flow.stage != HostedFlowStage.chooseCommunities && flow.stage != HostedFlowStage.complete && flow.stage != HostedFlowStage.browserReturned && flow.canRetry) diff --git a/mobile/lib/hosted_app_gate.dart b/mobile/lib/hosted_app_gate.dart index 56a5e615a5..6ab24b7c5a 100644 --- a/mobile/lib/hosted_app_gate.dart +++ b/mobile/lib/hosted_app_gate.dart @@ -112,7 +112,6 @@ class HostedAppGate extends HookConsumerWidget { }, [flow]); final inlineEntry = ref.watch(hostedInlineEntryProvider); final visible = - !flow.accountOnly && flow.stage != HostedFlowStage.idle && flow.stage != HostedFlowStage.complete; return DeepLinkDispatcher( @@ -198,7 +197,9 @@ class HostedAppGate extends HookConsumerWidget { children: [ child, if (visible && - (!inlineEntry || flow.stage == HostedFlowStage.chooseIdentity)) + (!inlineEntry || + flow.accountOnly || + flow.stage == HostedFlowStage.chooseIdentity)) HostedConnectPage( flow: flow, onClose: () => unawaited(flow.leave()), diff --git a/mobile/lib/onboarding_app_entry.dart b/mobile/lib/onboarding_app_entry.dart index e84635733e..5cc028fa63 100644 --- a/mobile/lib/onboarding_app_entry.dart +++ b/mobile/lib/onboarding_app_entry.dart @@ -31,8 +31,7 @@ class OnboardingAppEntry extends HookConsumerWidget { flow.accountOnly && (flow.stage == HostedFlowStage.working || flow.stage == HostedFlowStage.signIn); - final connected = - flow.accountOnly && flow.stage == HostedFlowStage.complete; + final connected = flow.account.account != null; final problem = flow.accountOnly ? flow.problem : null; final status = connected ? 'Connected to Builderlab. Your session is saved securely on this device.' @@ -64,12 +63,10 @@ class OnboardingAppEntry extends HookConsumerWidget { existingUser: existingUser.value, enteringCommunityLink: enteringCommunityLink.value, onCommunityLink: () => enteringCommunityLink.value = true, - onBuilderlab: busy || connected - ? null - : () => unawaited(flow.signInAccount()), + onBuilderlab: busy ? null : () => unawaited(flow.signInAccount()), accountStatus: status, builderlabLabel: connected - ? 'Connected to Builderlab' + ? 'Continue with Builderlab' : busy ? 'Signing in…' : 'Connect to Builderlab', diff --git a/mobile/lib/shared/hosted/hosted_communities.dart b/mobile/lib/shared/hosted/hosted_communities.dart new file mode 100644 index 0000000000..0d95ae1525 --- /dev/null +++ b/mobile/lib/shared/hosted/hosted_communities.dart @@ -0,0 +1,43 @@ +import '../deeplink/deep_link.dart'; +import 'builderlab_account.dart'; +import 'hosted_environment.dart'; + +/// Validated, active communities owned by the signed-in Builderlab identity. +/// Discovery never grants admission; each target still goes through HostedAdmission. +Future> loadHostedCommunities( + BuilderlabAccountClient account, +) async { + final response = await account.post('/v1/buzz/communities/list', {}); + final rows = response['communities']; + if (response['error'] != null || rows is! List) { + throw AccountFailure.invalidResponse; + } + final communities = {}; + for (final row in rows) { + if (row is! Map) throw AccountFailure.invalidResponse; + final archived = row['archived_at']; + if (archived != null) { + if (archived is! String || archived.isEmpty) { + throw AccountFailure.invalidResponse; + } + continue; + } + final host = row['normalized_host']; + final name = row['name']; + if (host is! String || + !RegExp(r'^[a-z0-9]+(?:[.-][a-z0-9]+)*$').hasMatch(host) || + name is! String || + name.trim().isEmpty) { + throw AccountFailure.invalidResponse; + } + final relay = Uri(scheme: 'wss', host: host); + if (!HostedEnvironment.supportsRelay(relay)) { + throw AccountFailure.invalidResponse; + } + communities.putIfAbsent( + host, + () => AddCommunityDeepLink(relayUrl: relay.toString(), name: name), + ); + } + return List.unmodifiable(communities.values); +} diff --git a/mobile/lib/shared/hosted/hosted_flow.dart b/mobile/lib/shared/hosted/hosted_flow.dart index 2803006f6e..321a7bae9b 100644 --- a/mobile/lib/shared/hosted/hosted_flow.dart +++ b/mobile/lib/shared/hosted/hosted_flow.dart @@ -12,6 +12,7 @@ import 'hosted_identity_service.dart'; import 'hosted_backup_service.dart'; import 'hpke_backup.dart'; import 'hosted_environment.dart'; +import 'hosted_communities.dart'; import 'web_connect_binding.dart'; enum HostedFlowStage { @@ -19,6 +20,7 @@ enum HostedFlowStage { working, signIn, chooseIdentity, + chooseCommunities, blocked, browserReturned, complete, @@ -76,6 +78,12 @@ class HostedFlow extends ChangeNotifier { List identityChoices = const []; BuzzDeepLink? _target; bool accountOnly = false; + List communities = const []; + Set selectedRelays = const {}; + final Set _addedRelays = {}; + bool _addingCommunities = false; + String? addingCommunityName; + int get addedCommunityCount => _addedRelays.length; Future _tail = Future.value(); StreamSubscription? _codes; StreamSubscription? _failures; @@ -96,8 +104,7 @@ class HostedFlow extends ChangeNotifier { _set(HostedFlowStage.working); await account.exchange(code: code.code, verifier: code.verifier); if (_disposed || generation != _generation) return; - await browser.clearIntent(); - _set(HostedFlowStage.complete); + await _continue(generation); return; } accountOnly = false; @@ -155,7 +162,8 @@ class HostedFlow extends ChangeNotifier { }); } - /// Account setup only: never resolve or generate a Nostr identity here. + /// Explicit Connect completes identity setup and discovers owned communities. + /// Passive startup restore only checks the saved session. Future signInAccount({bool openBrowser = true}) { if (_target != null || stage == HostedFlowStage.working || @@ -165,24 +173,27 @@ class HostedFlow extends ChangeNotifier { final generation = openBrowser ? ++_generation : _generation; if (openBrowser) { accountOnly = true; + communities = const []; + selectedRelays = const {}; + _addedRelays.clear(); + _addingCommunities = false; + addingCommunityName = null; _set(HostedFlowStage.working); } return _enqueue(() async { if (!openBrowser) { if (_target != null || stage != HostedFlowStage.idle) return; - accountOnly = true; - _set(HostedFlowStage.working); } final restored = await account.restore(); if (_disposed || generation != _generation) return; - if (restored != null) { - await browser.clearIntent(); - _set(HostedFlowStage.complete); - } else if (openBrowser) { + if (!openBrowser) { + // Mounting the welcome page must not create/bind/back up a key. + _set(HostedFlowStage.idle); + } else if (restored != null) { + await _continue(generation); + } else { _set(HostedFlowStage.signIn); await browser.start({'type': 'account'}); - } else { - _set(HostedFlowStage.idle); } }); } @@ -213,11 +224,26 @@ class HostedFlow extends ChangeNotifier { Future retry() async { final target = _target; - if (target == null) return; + if (target == null && !accountOnly) return; final generation = ++_generation; try { await browser.cancel(); - if (!_disposed && generation == _generation) await start(target); + if (_disposed || generation != _generation) return; + if (target != null) { + await start(target); + } else { + _set(HostedFlowStage.working); + await _enqueue(() async { + final restored = await account.restore(); + if (_disposed || generation != _generation) return; + if (restored == null) { + _set(HostedFlowStage.signIn); + await browser.start({'type': 'account'}); + } else { + await _continue(generation); + } + }); + } } catch (_) { if (!_disposed && generation == _generation) { _set(HostedFlowStage.blocked, HostedFlowProblem.retry); @@ -227,7 +253,7 @@ class HostedFlow extends ChangeNotifier { /// Resume only an offered public identity; reread local and cloud facts first. Future chooseIdentity(String pubkey) { - if (_target == null || + if ((_target == null && !accountOnly) || stage != HostedFlowStage.chooseIdentity || !identityChoices.any((item) => item.pubkey == pubkey)) { return Future.value(); @@ -240,8 +266,10 @@ class HostedFlow extends ChangeNotifier { Future _continue(int generation, {String? selectedPubkey}) async { final target = _target; final session = account.account; - if (target == null || session == null) throw AccountFailure.signedOut; - if (!_acceptTarget(target)) return; + if ((target == null && !accountOnly) || session == null) { + throw AccountFailure.signedOut; + } + if (target != null && !_acceptTarget(target)) return; var resolved = await identity.resolve( isCurrent: () => !_disposed && generation == _generation, selectedPubkey: selectedPubkey, @@ -338,6 +366,10 @@ class HostedFlow extends ChangeNotifier { _set(HostedFlowStage.blocked, failure); return; } + if (target == null) { + await _continueCommunities(generation, session.subject, pubkey); + return; + } await enterCommunity( target, session.subject, @@ -349,6 +381,89 @@ class HostedFlow extends ChangeNotifier { _set(HostedFlowStage.complete); } + void selectCommunity(String relay, bool selected) { + if (stage != HostedFlowStage.chooseCommunities || + !communities.any((item) => item.relayUrl == relay)) { + return; + } + selectedRelays = Set.unmodifiable({ + ...selectedRelays.where((item) => item != relay), + if (selected) relay, + }); + notifyListeners(); + } + + Future addSelectedCommunities() { + if (!accountOnly || + stage != HostedFlowStage.chooseCommunities || + selectedRelays.isEmpty) { + return Future.value(); + } + _addingCommunities = true; + final generation = ++_generation; + _set(HostedFlowStage.working); + return _enqueue(() => _continue(generation)); + } + + Future refreshCommunities() { + if (!accountOnly || stage != HostedFlowStage.chooseCommunities) { + return Future.value(); + } + return retry(); + } + + Future openCommunityCreation() async { + if (stage != HostedFlowStage.chooseCommunities) return; + final generation = _generation; + try { + if (!await _openWeb(HostedEnvironment.origin.resolve('/buzz'))) { + throw BrowserAuthFailure.unavailable; + } + } catch (error) { + if (!_disposed && generation == _generation) { + _set(HostedFlowStage.blocked, _problemFor(error)); + } + } + } + + Future _continueCommunities( + int generation, + String subject, + String pubkey, + ) async { + bool current() => + !_disposed && + generation == _generation && + account.account?.subject == subject; + if (!_addingCommunities) { + final listed = await loadHostedCommunities(account); + if (!current()) return; + communities = listed; + selectedRelays = Set.unmodifiable(listed.map((item) => item.relayUrl)); + if (listed.length != 1) { + _set(HostedFlowStage.chooseCommunities); + return; + } + _addingCommunities = true; + } + for (final community in communities) { + if (!current()) return; + if (!selectedRelays.contains(community.relayUrl) || + _addedRelays.contains(community.relayUrl)) { + continue; + } + addingCommunityName = community.name; + notifyListeners(); + await enterCommunity(community, subject, pubkey, current); + if (!current()) return; + _addedRelays.add(community.relayUrl); + } + if (!current()) return; + addingCommunityName = null; + await browser.clearIntent(); + if (current()) _set(HostedFlowStage.complete); + } + bool _acceptTarget(BuzzDeepLink target) { final relay = switch (target) { InviteDeepLink(:final relayUrl) => relayUrl, @@ -437,6 +552,12 @@ class HostedFlow extends ChangeNotifier { ++_generation; final generation = _generation; _target = null; + accountOnly = false; + communities = const []; + selectedRelays = const {}; + _addedRelays.clear(); + _addingCommunities = false; + addingCommunityName = null; try { await browser.cancel(); if (_disposed || generation != _generation) return; diff --git a/mobile/test/features/onboarding/hosted_communities_test.dart b/mobile/test/features/onboarding/hosted_communities_test.dart new file mode 100644 index 0000000000..242c1c159d --- /dev/null +++ b/mobile/test/features/onboarding/hosted_communities_test.dart @@ -0,0 +1,106 @@ +import 'package:buzz/features/onboarding/hosted_connect_page.dart'; +import 'package:buzz/hosted_app_gate.dart'; +import 'package:buzz/onboarding_app_entry.dart'; +import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; +import 'package:buzz/shared/hosted/hosted_flow.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/hosted_communities_fixture.dart'; +import '../../helpers/widget_helpers.dart'; + +void main() { + testWidgets( + 'welcome discovery selection and empty-state handoff use the real flow', + (tester) async { + // Keep the process-wide secure-store queue in one widget-test zone. + { + FlutterSecureStorage.setMockInitialValues({}); + PendingDeepLinkNotifier.debugUriStreamOverride = + const Stream.empty(); + final f = HostedCommunitiesFixture(); + await f.initialize(widget: true); + f.rows = [f.row('one'), f.row('two')]; + addTearDown(() async { + PendingDeepLinkNotifier.debugUriStreamOverride = null; + await f.dispose(); + }); + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + child: const HostedAppGate( + authenticated: false, + child: OnboardingAppEntry(), + ), + overrides: [hostedFlowProvider.overrideWithValue(f.flow)], + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Continue with Builderlab'), findsOneWidget); + expect(find.byType(HostedConnectPage), findsNothing); + await tester.tap(find.text('Continue with Builderlab')); + await tester.pumpAndSettle(); + expect(find.text('Choose your communities'), findsOneWidget); + expect( + tester + .widgetList(find.byType(CheckboxListTile)) + .every((tile) => tile.value == true), + isTrue, + ); + await tester.tap(find.text('one')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Continue').hitTestable()); + await tester.pumpAndSettle(); + expect(f.admitted, ['two']); + expect(find.byType(HostedConnectPage), findsNothing); + } + await tester.pumpWidget(const SizedBox.shrink()); + { + FlutterSecureStorage.setMockInitialValues({}); + PendingDeepLinkNotifier.debugUriStreamOverride = + const Stream.empty(); + final f = HostedCommunitiesFixture(); + await f.initialize(widget: true); + addTearDown(() async { + PendingDeepLinkNotifier.debugUriStreamOverride = null; + await f.dispose(); + }); + await f.flow.signInAccount(); + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + child: HostedConnectPage(flow: f.flow, onClose: f.flow.leave), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Create a community in Builderlab'), findsOneWidget); + await tester.tap(find.text('Create a community in Builderlab')); + await tester.pumpAndSettle(); + expect(f.opened?.path, '/buzz'); + expect(find.text('Refresh communities'), findsOneWidget); + await tester.enterText(find.byType(TextField), 'not a valid url'); + await tester.tap(find.text('Open community link')); + await tester.pumpAndSettle(); + expect( + find.text('Enter a valid community address or invite link.'), + findsOneWidget, + ); + await tester.enterText( + find.byType(TextField), + 'https://one.communities.buzz.xyz/invite/code', + ); + await tester.tap(find.text('Open community link')); + await tester.pumpAndSettle(); + expect(f.flow.stage, HostedFlowStage.idle); + final container = ProviderScope.containerOf( + tester.element(find.byType(HostedConnectPage)), + listen: false, + ); + expect(container.read(pendingDeepLinkProvider), isA()); + } + }, + ); +} diff --git a/mobile/test/helpers/hosted_communities_fixture.dart b/mobile/test/helpers/hosted_communities_fixture.dart new file mode 100644 index 0000000000..81d8bc8426 --- /dev/null +++ b/mobile/test/helpers/hosted_communities_fixture.dart @@ -0,0 +1,160 @@ +import 'dart:convert'; +import 'dart:async'; + +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/browser_auth.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/hosted_flow.dart'; +import 'package:buzz/shared/hosted/hosted_identity_service.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:nostr/nostr.dart' as nostr; + +class DiscoveryBrowser extends BrowserAuth { + final callbacks = StreamController.broadcast(); + @override + Stream get codes => callbacks.stream; + @override + Future dispose() async { + await callbacks.close(); + await super.dispose(); + } + + @override + Future initialize() async {} + @override + Future?> retainedIntent() async => null; + @override + Future clearIntent() async {} + @override + Future cancel() async {} +} + +class WidgetDiscoveryAccount extends BuilderlabAccountClient { + WidgetDiscoveryAccount(this.fixture); + final HostedCommunitiesFixture fixture; + @override + BuilderlabAccount get account => BuilderlabAccount( + subject: 'owner', + expiresAt: DateTime.now().add(const Duration(hours: 1)), + ); + @override + Future restore() async => account; + @override + Future> post( + String path, + Map body, + ) async { + if (path.endsWith('/current')) { + return { + 'identity': {'pubkey_hex': fixture.bound}, + }; + } + if (path.endsWith('/communities/list')) { + return {'communities': fixture.rows}; + } + throw AccountFailure.unavailable; + } +} + +class HostedCommunitiesFixture { + final keys = nostr.Keys('1'.padLeft(64, '0')); + final store = AccountIdentityStore(); + final browser = DiscoveryBrowser(); + late BuilderlabAccountClient account; + late final HostedFlow flow; + final admitted = []; + final requests = []; + List> rows = []; + String? bound; + bool rejectList = false; + Future Function(String)? beforeAdmission; + Future Function()? beforeList; + Uri? opened; + + Map row(String name, {bool archived = false}) => { + 'id': name, + 'name': name, + 'normalized_host': '$name.communities.buzz.xyz', + if (archived) 'archived_at': '2026-10-01T00:00:00Z', + }; + + Future initialize({bool backup = true, bool widget = false}) async { + bound = keys.public; + final expiry = DateTime.now() + .toUtc() + .add(const Duration(hours: 1)) + .toIso8601String(); + account = BuilderlabAccountClient( + httpClient: MockClient((request) async { + final path = request.url.path; + requests.add(path); + if (path.endsWith('/exchange')) { + return http.Response( + jsonEncode({'session_credential': 'fixture', 'expires_at': expiry}), + 200, + ); + } + if (path.endsWith('/me')) { + return http.Response( + jsonEncode({'subject': 'owner', 'expires_at': expiry}), + 200, + ); + } + if (path.endsWith('/current')) { + return http.Response( + jsonEncode({ + 'identity': {'pubkey_hex': bound}, + }), + 200, + ); + } + if (path.endsWith('/communities/list')) { + await beforeList?.call(); + if (rejectList) return http.Response('{}', 503); + return http.Response(jsonEncode({'communities': rows}), 200); + } + return http.Response('{}', 503); + }), + ); + if (widget) { + account.dispose(); + account = WidgetDiscoveryAccount(this); + } else { + await account.exchange(code: 'code', verifier: 'a' * 43); + } + await store.create(keys.nsec); + if (backup) { + await store.confirmBackup( + BackupReceipt( + backend: BuilderlabAccountClient.origin.toString(), + subject: 'owner', + pubkey: keys.public, + backupId: 'fixture-backup', + ), + ); + } + flow = HostedFlow( + account: account, + browser: browser, + store: store, + identity: HostedIdentityService(account: account, store: store), + openWeb: (uri) async { + opened = uri; + return true; + }, + enterCommunity: (link, subject, pubkey, current) async { + final target = link as AddCommunityDeepLink; + await beforeAdmission?.call(target.name); + if (current()) admitted.add(target.name); + }, + ); + } + + Future dispose() async { + flow.dispose(); + await browser.dispose(); + account.dispose(); + } +} diff --git a/mobile/test/shared/hosted/hosted_communities_flow_test.dart b/mobile/test/shared/hosted/hosted_communities_flow_test.dart new file mode 100644 index 0000000000..183ae47d16 --- /dev/null +++ b/mobile/test/shared/hosted/hosted_communities_flow_test.dart @@ -0,0 +1,207 @@ +import 'dart:async'; + +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/browser_auth.dart'; +import 'package:buzz/shared/hosted/hosted_flow.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/hosted_communities_fixture.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => FlutterSecureStorage.setMockInitialValues({})); + + Future fixture({bool backup = true}) async { + final result = HostedCommunitiesFixture(); + await result.initialize(backup: backup); + addTearDown(result.dispose); + return result; + } + + test( + 'account browser callback continues into discovery after exchange', + () async { + final f = await fixture(); + f.rows = [f.row('one'), f.row('two')]; + await f.flow.initialize(); + final selected = Completer(); + f.flow.addListener(() { + if (f.flow.stage == HostedFlowStage.chooseCommunities && + !selected.isCompleted) { + selected.complete(); + } + }); + f.browser.callbacks.add( + BrowserAuthCode('returned-code', 'a' * 43, {'type': 'account'}), + ); + await selected.future.timeout(const Duration(seconds: 2)); + expect(f.flow.selectedRelays.length, 2); + expect(f.admitted, isEmpty); + }, + ); + + test( + 'passive session restore cannot discover or enter communities', + () async { + final f = await fixture(); + f.rows = [f.row('one')]; + await f.flow.signInAccount(openBrowser: false); + expect(f.flow.stage, HostedFlowStage.idle); + expect(f.requests.any((path) => path.endsWith('/current')), isFalse); + expect(f.admitted, isEmpty); + await f.flow.signInAccount(); + expect(f.admitted, ['one']); + expect(f.flow.stage, HostedFlowStage.complete); + }, + ); + + test( + 'filters archived and duplicate hosts before direct single entry', + () async { + final f = await fixture(); + f.rows = [f.row('archived', archived: true), f.row('one'), f.row('one')]; + await f.flow.signInAccount(); + expect(f.admitted, ['one']); + }, + ); + + test( + 'multiple communities default selected; only selected ones enter', + () async { + final f = await fixture(); + f.rows = [f.row('one'), f.row('two')]; + await f.flow.signInAccount(); + expect(f.flow.stage, HostedFlowStage.chooseCommunities); + expect(f.flow.selectedRelays.length, 2); + expect(f.admitted, isEmpty); + f.flow.selectCommunity('wss://one.communities.buzz.xyz', false); + await f.flow.addSelectedCommunities(); + expect(f.admitted, ['two']); + }, + ); + + test('empty selection does not complete or admit', () async { + final f = await fixture(); + f.rows = [f.row('one'), f.row('two')]; + await f.flow.signInAccount(); + for (final relay in f.flow.selectedRelays.toList()) { + f.flow.selectCommunity(relay, false); + } + await f.flow.addSelectedCommunities(); + expect(f.flow.stage, HostedFlowStage.chooseCommunities); + expect(f.admitted, isEmpty); + }); + + test( + 'partial failure preserves successes and retry only adds remaining', + () async { + final f = await fixture(); + f.rows = [f.row('one'), f.row('two'), f.row('three')]; + f.beforeAdmission = (name) async { + if (name == 'two') throw AccountFailure.unavailable; + }; + await f.flow.signInAccount(); + await f.flow.addSelectedCommunities(); + expect(f.flow.stage, HostedFlowStage.blocked); + expect(f.admitted, ['one']); + expect(f.flow.addedCommunityCount, 1); + f.beforeAdmission = null; + await f.flow.retry(); + expect(f.admitted, ['one', 'two', 'three']); + expect(f.flow.stage, HostedFlowStage.complete); + }, + ); + + test('cancellation fences admission and stale selection', () async { + final f = await fixture(); + f.rows = [f.row('one')]; + final started = Completer(); + final release = Completer(); + f.beforeAdmission = (_) async { + started.complete(); + await release.future; + }; + final pending = f.flow.signInAccount(); + await started.future; + await f.flow.leave(); + release.complete(); + await pending; + await f.flow.addSelectedCommunities(); + expect(f.admitted, isEmpty); + expect(f.flow.stage, HostedFlowStage.idle); + }); + + test('cancelled list cannot display or auto-add its late response', () async { + final f = await fixture(); + f.rows = [f.row('one')]; + final started = Completer(); + final release = Completer(); + f.beforeList = () async { + started.complete(); + await release.future; + }; + final pending = f.flow.signInAccount(); + await started.future; + await f.flow.leave(); + release.complete(); + await pending; + expect(f.admitted, isEmpty); + expect(f.flow.stage, HostedFlowStage.idle); + }); + + test( + 'zero communities can open creation and refresh into single entry', + () async { + final f = await fixture(); + await f.flow.signInAccount(); + expect(f.flow.stage, HostedFlowStage.chooseCommunities); + await f.flow.openCommunityCreation(); + expect(f.opened, Uri.parse('https://app.builderlab.xyz/buzz')); + f.rows = [f.row('created')]; + await f.flow.refreshCommunities(); + expect(f.admitted, ['created']); + }, + ); + + test('identity mismatch blocks list and preserves saved key', () async { + final f = await fixture(); + f.bound = '2' * 64; + await f.flow.signInAccount(); + expect(f.flow.problem, HostedFlowProblem.identityMismatch); + expect( + f.requests.any((path) => path.endsWith('/communities/list')), + isFalse, + ); + expect((await f.store.read())?.pubkey, f.keys.public); + expect(f.admitted, isEmpty); + }); + + test('missing backup cannot unlock discovery or admission', () async { + final f = await fixture(backup: false); + await f.flow.signInAccount(); + expect(f.flow.stage, HostedFlowStage.blocked); + expect( + f.requests.any((path) => path.endsWith('/communities/list')), + isFalse, + ); + expect(f.admitted, isEmpty); + }); + + test( + 'untrusted list targets and failed discovery never look like empty lists', + () async { + final f = await fixture(); + f.rows = [ + {...f.row('one'), 'normalized_host': 'evil.example'}, + ]; + await f.flow.signInAccount(); + expect(f.flow.problem, HostedFlowProblem.invalidResponse); + expect(f.admitted, isEmpty); + f.rejectList = true; + await f.flow.signInAccount(); + expect(f.flow.stage, HostedFlowStage.blocked); + expect(f.flow.problem, HostedFlowProblem.retry); + }, + ); +}