diff --git a/mobile/ios/Runner/NativeProfileSetup.swift b/mobile/ios/Runner/NativeProfileSetup.swift index 428b560c28..74d6cbab07 100644 --- a/mobile/ios/Runner/NativeProfileSetup.swift +++ b/mobile/ios/Runner/NativeProfileSetup.swift @@ -3,7 +3,7 @@ import UIKit import CoreText /// Profile draft presentation; persistence and the existing avatar editor stay in Dart. -final class NativeProfileSetupCoordinator: NSObject, UIAdaptivePresentationControllerDelegate { +final class NativeProfileSetupCoordinator: NSObject, UIAdaptivePresentationControllerDelegate, UIViewControllerTransitioningDelegate { private let channel: FlutterMethodChannel private var navigation: UINavigationController? private var pending: FlutterResult? @@ -81,6 +81,7 @@ final class NativeProfileSetupCoordinator: NSObject, UIAdaptivePresentationContr } let nav = UINavigationController(rootViewController: controller) nav.modalPresentationStyle = .overFullScreen + nav.transitioningDelegate = self nav.overrideUserInterfaceStyle = args["dark"] as? Bool == true ? .dark : .light self.pending = result; self.session = session; self.navigation = nav controller.loadViewIfNeeded() @@ -257,6 +258,50 @@ final class NativeProfileSetupCoordinator: NSObject, UIAdaptivePresentationContr result?(action) } func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { complete("skip") } + + func animationController(forPresented presented: UIViewController, presenting: UIViewController, + source: UIViewController) -> UIViewControllerAnimatedTransitioning? { + ProfileSetupPageTransition(presenting: true) + } + + func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { + ProfileSetupPageTransition(presenting: false) + } +} + +/// Matches the horizontal forward/backward motion of the Flutter onboarding pages. +private final class ProfileSetupPageTransition: NSObject, UIViewControllerAnimatedTransitioning { + let presenting: Bool + init(presenting: Bool) { self.presenting = presenting } + + func transitionDuration(using context: UIViewControllerContextTransitioning?) -> TimeInterval { 0.25 } + + func animateTransition(using context: UIViewControllerContextTransitioning) { + let key: UITransitionContextViewControllerKey = presenting ? .to : .from + guard let controller = context.viewController(forKey: key) else { + context.completeTransition(false) + return + } + guard let view = context.view(forKey: presenting ? .to : .from) ?? controller.view else { + context.completeTransition(false) + return + } + let distance = context.containerView.bounds.width + if presenting { + view.frame = context.finalFrame(for: controller) + context.containerView.addSubview(view) + view.transform = CGAffineTransform(translationX: distance, y: 0) + } + UIView.animate(withDuration: transitionDuration(using: context), delay: 0, + options: [.curveEaseOut, .beginFromCurrentState]) { + view.transform = self.presenting ? .identity : CGAffineTransform(translationX: distance, y: 0) + } completion: { _ in + let completed = !context.transitionWasCancelled + if !self.presenting && completed { view.removeFromSuperview() } + view.transform = .identity + context.completeTransition(completed) + } + } } private final class NativeProfileSetupController: UIViewController, UITextFieldDelegate { diff --git a/mobile/ios/RunnerTests/ProfileSetupRecoveryTests.swift b/mobile/ios/RunnerTests/ProfileSetupRecoveryTests.swift index 0f9a48a121..5e6c927e37 100644 --- a/mobile/ios/RunnerTests/ProfileSetupRecoveryTests.swift +++ b/mobile/ios/RunnerTests/ProfileSetupRecoveryTests.swift @@ -88,6 +88,7 @@ final class ProfileSetupRecoveryTests: XCTestCase { let avatar = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton } .first { $0.accessibilityLabel == "Add profile photo" }) XCTAssertEqual(nav.modalPresentationStyle, .overFullScreen) + XCTAssertTrue(nav.transitioningDelegate === coordinator) XCTAssertNil(profile.navigationItem.rightBarButtonItem) let name = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UITextField }.first) let submit = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton } diff --git a/mobile/lib/features/onboarding/hosted_community_selection.dart b/mobile/lib/features/onboarding/hosted_community_selection.dart index 38fcf00ffb..45f3025402 100644 --- a/mobile/lib/features/onboarding/hosted_community_selection.dart +++ b/mobile/lib/features/onboarding/hosted_community_selection.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 '../../shared/hosted/hosted_environment.dart'; import '../../shared/theme/grid.dart'; import '../../shared/community/community_grid.dart'; import '../../shared/community/community_grid_option.dart'; @@ -63,7 +64,10 @@ class HostedCommunitySelection extends HookConsumerWidget { for (final community in flow.communities) CommunityGridOption( id: community.relayUrl, - name: community.name, + name: HostedEnvironment.communityDisplayName( + community.name, + community.relayUrl, + ), relayUrl: community.relayUrl, onSelect: (_) async { flow.chooseLandingCommunity(community.relayUrl); diff --git a/mobile/lib/features/onboarding/hosted_connect_page.dart b/mobile/lib/features/onboarding/hosted_connect_page.dart index 8fb117a4f9..07f7f064b0 100644 --- a/mobile/lib/features/onboarding/hosted_connect_page.dart +++ b/mobile/lib/features/onboarding/hosted_connect_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import '../../shared/widgets/onboarding_route.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -31,26 +32,20 @@ class HostedConnectPage extends HookConsumerWidget { final stepsNavigator = GlobalKey(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!active || !context.mounted) return; - route = PageRouteBuilder( + route = onboardingRoute( + context, settings: const RouteSettings(name: 'hosted-setup'), - transitionDuration: Duration.zero, - reverseTransitionDuration: Duration.zero, - pageBuilder: (_, _, _) => PopScope( + builder: (_) => PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (!didPop) stepsNavigator.currentState?.maybePop(); }, - child: Navigator( - key: stepsNavigator, - onGenerateRoute: (_) => MaterialPageRoute( - builder: (_) => _HostedSteps( - flow: flow, - onAddCommunity: onAddCommunity, - onClose: () => latest.value.onClose(), - profileBuilder: (context) => - latest.value.profileBuilder(context), - ), - ), + child: _HostedSteps( + navigatorKey: stepsNavigator, + flow: flow, + onAddCommunity: onAddCommunity, + onClose: () => latest.value.onClose(), + profileBuilder: (context) => latest.value.profileBuilder(context), ), ), ); @@ -80,11 +75,13 @@ class HostedConnectPage extends HookConsumerWidget { class _HostedSteps extends HookWidget { const _HostedSteps({ + required this.navigatorKey, required this.flow, this.onAddCommunity, required this.onClose, required this.profileBuilder, }); + final GlobalKey navigatorKey; final HostedFlow flow; final VoidCallback? onAddCommunity; final VoidCallback onClose; @@ -93,63 +90,53 @@ class _HostedSteps extends HookWidget { @override Widget build(BuildContext context) { useListenable(flow); - final reducedMotion = MediaQuery.disableAnimationsOf(context); final presentedStage = flow.stage; final presentation = Object(); final activePresentation = useRef(null)..value = presentation; - // Keep an opaque surface behind the native profile presentation and its - // dismissal. Completion retires the route only after the flow settles. - if (flow.stage == HostedFlowStage.profile) { - return ColoredBox( - color: context.colors.surface, - child: profileBuilder(context), - ); + // Native profile owns its entry animation; keep its underlying page stable. + final pageStage = useRef(flow.stage); + if (flow.stage != HostedFlowStage.profile) pageStage.value = flow.stage; + void goBack() { + if (activePresentation.value != presentation || + flow.stage != presentedStage) { + return; + } + // Nested PopScopes can observe the same rejected system pop. Retire + // this presentation before dispatching its one back/close action. + activePresentation.value = null; + if (flow.canChangeCommunities) { + flow.backToCommunityChoices(); + } else { + onClose(); + } } - return ColoredBox( - color: context.colors.surface, - child: AnimatedSwitcher( - duration: reducedMotion - ? Duration.zero - : const Duration(milliseconds: 250), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - layoutBuilder: (current, previous) => Stack( - fit: StackFit.expand, - children: [ - for (final child in previous) - ExcludeFocus( - child: ExcludeSemantics(child: IgnorePointer(child: child)), - ), - ?current, - ], + + return Navigator( + key: navigatorKey, + onDidRemovePage: (_) {}, + pages: [ + OnboardingPage( + key: ValueKey(pageStage.value), + child: PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) goBack(); + }, + child: flow.stage == HostedFlowStage.profile + ? ColoredBox( + color: context.colors.surface, + child: profileBuilder(context), + ) + : HostedConnectSheet( + flow: flow, + onAddCommunity: onAddCommunity, + presentedStage: presentedStage, + fullScreen: true, + onBack: goBack, + ), + ), ), - transitionBuilder: (child, animation) => SlideTransition( - position: Tween( - begin: const Offset(1, 0), - end: Offset.zero, - ).animate(animation), - child: child, - ), - child: HostedConnectSheet( - key: ValueKey(flow.stage), - flow: flow, - onAddCommunity: onAddCommunity, - presentedStage: flow.stage, - fullScreen: true, - onBack: () { - // Outgoing pages remain mounted until their transition finishes. - if (activePresentation.value != presentation || - flow.stage != presentedStage) { - return; - } - if (flow.canChangeCommunities) { - flow.backToCommunityChoices(); - } else { - onClose(); - } - }, - ), - ), + ], ); } } diff --git a/mobile/lib/features/onboarding/hosted_connect_sheet.dart b/mobile/lib/features/onboarding/hosted_connect_sheet.dart index c779c45289..748af4a2e7 100644 --- a/mobile/lib/features/onboarding/hosted_connect_sheet.dart +++ b/mobile/lib/features/onboarding/hosted_connect_sheet.dart @@ -224,12 +224,12 @@ class HostedConnectSheet extends HookConsumerWidget { child: const Text('Keep saved identity and skip'), ), if (flow.canSkipFailedCommunity) - OutlinedButton( + TextButton( onPressed: flow.skipFailedCommunity, child: const Text('Skip this community'), ), if (flow.canChangeCommunities) - OutlinedButton( + TextButton( onPressed: flow.backToCommunityChoices, child: const Text('Back to choices'), ), diff --git a/mobile/lib/features/onboarding/saved_identity_card.dart b/mobile/lib/features/onboarding/saved_identity_card.dart new file mode 100644 index 0000000000..3a8e139c37 --- /dev/null +++ b/mobile/lib/features/onboarding/saved_identity_card.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/hosted/device_identity_source.dart'; +import '../../shared/hosted/saved_identity_profile.dart'; +import '../../shared/profile/user_cache_provider.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/theme/buzz_icons.dart'; +import '../../shared/utils/string_utils.dart'; +import '../../shared/widgets/avatar_image.dart'; + +/// An explicit saved-key choice using available cached profile metadata. +class SavedIdentityCard extends ConsumerWidget { + const SavedIdentityCard({ + super.key, + required this.identity, + required this.onSelect, + }); + + final DeviceIdentityChoice identity; + final VoidCallback? onSelect; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final cachedProfile = ref.watch( + userCacheProvider.select((profiles) => profiles[identity.pubkey]), + ); + final profile = + cachedProfile ?? + ref.watch(savedIdentityProfileProvider(identity.pubkey)).asData?.value; + final profileName = profile?.displayName; + final name = profileName != null && profileName.trim().isNotEmpty + ? profileName + : 'Saved identity'; + return Semantics( + button: true, + enabled: onSelect != null, + label: 'Use $name, ${shortPubkey(identity.pubkey)}', + onTap: onSelect, + excludeSemantics: true, + child: OutlinedButton( + onPressed: onSelect, + style: OutlinedButton.styleFrom( + backgroundColor: context.colors.surfaceContainerHighest, + padding: const EdgeInsets.all(Grid.xs), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.card), + ), + foregroundColor: context.colors.onSurface, + alignment: Alignment.centerLeft, + ), + child: Row( + children: [ + AvatarImage( + imageUrl: profile?.avatarUrl, + radius: 24, + fallback: Center( + child: Text( + name.isEmpty ? '?' : name.characters.first.toUpperCase(), + ), + ), + ), + const SizedBox(width: Grid.twelve), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: context.textTheme.titleSmall?.copyWith(height: 1.5), + ), + const SizedBox(height: Grid.half), + Text( + shortPubkey(identity.pubkey), + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: Grid.xxs), + const Icon(BuzzIcons.chevronRight, size: 20), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/onboarding/self_hosted_entry_sheet.dart b/mobile/lib/features/onboarding/self_hosted_entry_sheet.dart index bd381ad01b..5c4b414036 100644 --- a/mobile/lib/features/onboarding/self_hosted_entry_sheet.dart +++ b/mobile/lib/features/onboarding/self_hosted_entry_sheet.dart @@ -9,9 +9,11 @@ import '../../shared/hosted/self_hosted_entry.dart'; import '../../shared/identity_transfer/nsec_import_sheet.dart'; import '../../shared/identity_transfer/native_community_entry.dart'; import '../../shared/theme/theme.dart'; -import '../../shared/utils/string_utils.dart'; +import '../../shared/hosted/hosted_environment.dart'; +import 'saved_identity_card.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../../shared/widgets/onboarding_step_page.dart'; +import '../../shared/widgets/onboarding_loading_page.dart'; import '../../shared/widgets/buzz_titled_sheet_layout.dart'; /// Local preparation seam; the production service validates and retains keys. @@ -54,7 +56,10 @@ Future showSelfHostedEntry( if (!context.mounted) return false; if (choices != null) { final result = await NativeCommunityEntry.show( - name: name.isEmpty ? Uri.parse(relay).host : name, + name: HostedEnvironment.communityDisplayName( + name.isEmpty ? Uri.parse(relay).host : name, + relay, + ), dark: dark, primaryArgb: primary, choices: choices, @@ -187,8 +192,8 @@ class SelfHostedEntrySheet extends HookConsumerWidget { onPressed: onPressed, child: child, ) - : OutlinedButton( - style: OutlinedButton.styleFrom( + : TextButton( + style: TextButton.styleFrom( foregroundColor: context.colors.onSurface, minimumSize: const Size.fromHeight(52), shape: const StadiumBorder(), @@ -199,6 +204,74 @@ class SelfHostedEntrySheet extends HookConsumerWidget { } final saved = choices.data?.choices ?? const []; + final identityTitle = saved.isEmpty + ? 'Choose an identity' + : saved.length == 1 + ? 'You already have a saved identity' + : 'You already have saved identities'; + final destination = HostedEnvironment.communityDisplayName( + name.isEmpty ? Uri.parse(relay).host : name, + relay, + ); + final identitySubtitle = + 'Join $destination. ${saved.length == 1 ? 'Use this identity or import a Nostr key.' : 'Choose an identity or import a Nostr key.'}'; + final identityCards = [ + for (final identity in saved) + SavedIdentityCard( + identity: identity, + onSelect: busy.value ? null : () => select(pubkey: identity.pubkey), + ), + ]; + final joinActions = [ + if (choices.connectionState == ConnectionState.done) ...[ + button( + onPressed: busy.value + ? null + : () async { + if (fullScreen) { + final imported = await Navigator.of(context).push( + MaterialPageRoute( + builder: (importContext) => NsecImportSheet( + fullScreen: true, + onBack: () { + generation.value++; + busy.value = false; + error.value = null; + Navigator.of(importContext).pop(false); + }, + onImport: (nsec) => connect(nsec: nsec, close: false), + ), + ), + ); + if (imported == true && context.mounted) { + Navigator.of(context).pop(true); + } + } else { + importFocusReady.value = false; + importing.value = true; + } + }, + child: const Text('Import a Nostr key'), + ), + if (saved.isEmpty && choices.data?.complete == true) + button( + primary: true, + onPressed: busy.value ? null : () => select(create: true), + child: const Text('Create an identity and join'), + ), + if (saved.isEmpty && choices.data?.complete == true) + const Text( + 'Export a backup in Settings after joining. This device will hold your only copy until you back it up.', + ), + ], + if (busy.value) + const Padding( + padding: EdgeInsets.all(Grid.sm), + child: Text('Joining…'), + ), + if (error.value != null) + Text(error.value!, style: TextStyle(color: context.colors.error)), + ]; final joinContent = Padding( padding: fullScreen ? EdgeInsets.zero : const EdgeInsets.all(Grid.gutter), child: SingleChildScrollView( @@ -206,103 +279,51 @@ class SelfHostedEntrySheet extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Container( - padding: const EdgeInsets.all(Grid.sm), - decoration: BoxDecoration( - color: context.theme.brightness == Brightness.dark - ? const Color(0xFF233039) - : Colors.white.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: context.colors.primary.withValues(alpha: 0.15), - ), - ), - child: Text( - name.isEmpty ? Uri.parse(relay).host : name, - textAlign: TextAlign.center, - style: context.textTheme.titleMedium, - ), - ), - const SizedBox(height: Grid.sm), if (choices.hasError || choices.data?.complete == false) const Text( 'Some saved identities could not be read. You can use an available identity or import a key.', ), - for (final identity in saved) - button( - onPressed: busy.value - ? null - : () => select(pubkey: identity.pubkey), - child: Column( - children: [ - Text(identity.label), - Text(fullNpub(identity.pubkey) ?? identity.pubkey), - ], - ), - ), - if (choices.connectionState == ConnectionState.done) ...[ - button( - onPressed: busy.value - ? null - : () async { - if (fullScreen) { - final imported = await Navigator.of(context) - .push( - MaterialPageRoute( - builder: (importContext) => NsecImportSheet( - fullScreen: true, - onBack: () { - generation.value++; - busy.value = false; - error.value = null; - Navigator.of(importContext).pop(false); - }, - onImport: (nsec) => - connect(nsec: nsec, close: false), - ), - ), - ); - if (imported == true && context.mounted) { - Navigator.of(context).pop(true); - } - } else { - importFocusReady.value = false; - importing.value = true; - } - }, - child: const Text('Import a Nostr key'), - ), - if (saved.isEmpty && choices.data?.complete == true) - button( - primary: true, - onPressed: busy.value ? null : () => select(create: true), - child: const Text('Create an identity and join'), - ), - if (saved.isEmpty && choices.data?.complete == true) - const Text( - 'Export a backup in Settings after joining. This device will hold your only copy until you back it up.', - ), + if (!fullScreen) ...[ + Text(identitySubtitle, style: context.textTheme.bodyMedium), + const SizedBox(height: Grid.sm), + ], + // The full-screen page already provides 16 px below its subtitle. + if (fullScreen && identityCards.isNotEmpty) + const SizedBox(height: Grid.xxs), + for (var i = 0; i < identityCards.length; i++) ...[ + if (i > 0) const SizedBox(height: Grid.xxs), + identityCards[i], + ], + if (!fullScreen) ...[ + if (identityCards.isNotEmpty) const SizedBox(height: Grid.xxs), + for (var i = 0; i < joinActions.length; i++) ...[ + if (i > 0) const SizedBox(height: Grid.xxs), + joinActions[i], + ], ], - if (busy.value) - const Padding( - padding: EdgeInsets.all(Grid.sm), - child: Text('Joining…'), - ), - if (error.value != null) - Text(error.value!, style: TextStyle(color: context.colors.error)), ], ), ), ); + if (fullScreen && busy.value) { + return OnboardingLoadingPage( + title: 'Getting ready', + onCancel: () { + generation.value++; + busy.value = false; + }, + ); + } if (fullScreen) { return OnboardingStepPage( - title: 'Join community', - subtitle: 'Select an identity to use in this community.', + title: identityTitle, + subtitle: identitySubtitle, onBack: () { generation.value++; Navigator.of(context).pop(false); }, content: joinContent, + actions: joinActions, ); } return AnimatedSize( @@ -315,7 +336,7 @@ class SelfHostedEntrySheet extends HookConsumerWidget { if (context.mounted && importing.value) importFocusReady.value = true; }, child: BuzzTitledSheetLayout( - title: importing.value ? 'Import identity' : 'Join community', + title: importing.value ? 'Import identity' : 'Choose an identity', onClose: importing.value ? () { generation.value++; diff --git a/mobile/lib/features/pairing/add_community_page.dart b/mobile/lib/features/pairing/add_community_page.dart index 9f5aa5daad..8a99c70936 100644 --- a/mobile/lib/features/pairing/add_community_page.dart +++ b/mobile/lib/features/pairing/add_community_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart'; import 'community_link_focus.dart'; import '../../shared/widgets/ios_community_link_input.dart'; import 'package:flutter/material.dart'; +import '../../shared/widgets/onboarding_route.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -80,27 +81,20 @@ class AddCommunityPage extends StatelessWidget { errorMessage: errorMessage, onInputChanged: onInputChanged, ); - return AnimatedSwitcher( - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 250), - layoutBuilder: (current, previous) => Stack( - fit: StackFit.expand, - children: [ - for (final child in previous) - ExcludeFocus( - child: ExcludeSemantics(child: IgnorePointer(child: child)), - ), - ?current, + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) onBack(); + }, + child: Navigator( + onDidRemovePage: (_) {}, + pages: [ + OnboardingPage( + key: ValueKey(step == 'pair' ? 'pair' : 'connection'), + child: page, + ), ], ), - transitionBuilder: (child, animation) => SlideTransition( - position: Tween(begin: const Offset(1, 0), end: Offset.zero).animate( - CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), - ), - child: child, - ), - child: page, ); } } diff --git a/mobile/lib/features/pairing/add_community_page/add_community_connection.dart b/mobile/lib/features/pairing/add_community_page/add_community_connection.dart index 58d8724ad6..a0928bfed7 100644 --- a/mobile/lib/features/pairing/add_community_page/add_community_connection.dart +++ b/mobile/lib/features/pairing/add_community_page/add_community_connection.dart @@ -163,7 +163,7 @@ class _AddCommunityConnection extends HookWidget { key: const Key('add-community-link-action'), style: FilledButton.styleFrom( backgroundColor: Color.lerp( - context.colors.surface, + Colors.transparent, context.colors.primary, progress, ), @@ -172,9 +172,7 @@ class _AddCommunityConnection extends HookWidget { context.colors.onPrimary, progress, ), - side: BorderSide( - color: context.colors.outline.withValues(alpha: 1 - progress), - ), + side: BorderSide.none, ), onPressed: busy || (enteringLink && !canSubmit) ? null @@ -196,7 +194,7 @@ class _AddCommunityConnection extends HookWidget { ), ), if (pairingVisible.value && !enteringLink) - OutlinedButton( + TextButton( onPressed: busy ? null : onPairing, child: const Text('Use a pairing code'), ), diff --git a/mobile/lib/features/profile/profile_setup_page.dart b/mobile/lib/features/profile/profile_setup_page.dart index 42e6199c45..3982c88c04 100644 --- a/mobile/lib/features/profile/profile_setup_page.dart +++ b/mobile/lib/features/profile/profile_setup_page.dart @@ -14,6 +14,8 @@ import '../../shared/theme/theme.dart'; // ignore: unused_import import 'profile_avatar_sheet_app.dart'; import '../../shared/widgets/modal_presentation.dart'; +import '../../shared/widgets/onboarding_step_page.dart'; +import '../../shared/widgets/onboarding_route.dart'; import 'profile_avatar_draft.dart'; import '../../shared/widgets/playing_avatar_image.dart'; import 'profile_setup_avatar_editor.dart'; @@ -27,7 +29,8 @@ class ProfileSetupPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final theme = utilitySurfaceThemeData(Theme.of(context)); + useListenable(flow); + final theme = Theme.of(context); final appearance = { 'dark': theme.brightness == Brightness.dark, 'pageBackgroundArgb': theme.colorScheme.surface.toARGB32(), @@ -88,25 +91,24 @@ class ProfileSetupPage extends HookConsumerWidget { Future fallback() async { if (!current()) return; - await showBuzzModalBottomSheet( - context: context, - title: 'Set up your profile', - isDismissible: false, - enableDrag: false, - showCloseButton: false, - useRootNavigator: true, - isScrollControlled: true, - useSafeArea: true, - builder: (sheetContext) { - profileRoute = ModalRoute.of(sheetContext); - return _ProfileSetupForm( - avatar: avatar, - onName: (value) => name = value, - editAvatar: editAvatar, - save: save, - notNow: flow.finishProfile, - ); - }, + await Navigator.of(context, rootNavigator: true).push( + onboardingRoute( + context, + builder: (sheetContext) { + profileRoute = ModalRoute.of(sheetContext); + return OnboardingStepPage( + title: 'Set up your profile', + onBack: null, + content: _ProfileSetupForm( + avatar: avatar, + onName: (value) => name = value, + editAvatar: editAvatar, + save: save, + notNow: flow.finishProfile, + ), + ); + }, + ), ); if (!current()) return; profileRoute = null; @@ -215,7 +217,7 @@ class ProfileSetupPage extends HookConsumerWidget { } }); }; - }, [flow]); + }, [flow, flow.stage == HostedFlowStage.profile]); useEffect(() { if (defaultTargetPlatform != TargetPlatform.iOS) return null; WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/mobile/lib/onboarding_app_entry.dart b/mobile/lib/onboarding_app_entry.dart index f183442b90..6ac2dec14c 100644 --- a/mobile/lib/onboarding_app_entry.dart +++ b/mobile/lib/onboarding_app_entry.dart @@ -94,7 +94,7 @@ class OnboardingAppEntry extends HookConsumerWidget { : flow.noCommunitiesToAdd ? 'No communities available to add. Scan a QR code or use an invite link.' : connected - ? 'Connected to Builderlab.' + ? 'Scan a QR code or enter a community link.' : null; final handlingBack = useRef(false); void back() { diff --git a/mobile/lib/shared/hosted/device_identity_source.dart b/mobile/lib/shared/hosted/device_identity_source.dart index aef15e53a0..c22264a6a7 100644 --- a/mobile/lib/shared/hosted/device_identity_source.dart +++ b/mobile/lib/shared/hosted/device_identity_source.dart @@ -5,6 +5,8 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../storage/secure_storage.dart'; import 'account_identity_store.dart'; +import '../profile/user_profile.dart'; +import '../relay/relay_provider.dart'; /// Public identity choices; never contains private signing material. class DeviceIdentityChoice { @@ -26,6 +28,40 @@ class DeviceIdentitySource { DeviceIdentityChoice(entry.key, entry.value.label), ]; + /// Read profile metadata only from communities already saved for this key. + /// A failed/offline community does not prevent trying another saved source. + Future loadProfile( + String pubkey, + Future Function(RelayConfig config) fetch, + ) async { + final seen = {}; + for (final key in ['buzz_communities', 'buzz_workspaces']) { + final raw = await _storage.read(key: key); + if (raw == null) continue; + for (final record in jsonDecode(raw) as List) { + try { + final value = record as Map; + final nsec = value['nsec'] as String?; + final relay = value['relayUrl'] as String?; + if (nsec == null || + relay == null || + AccountIdentityStore.publicKeyForNsec(nsec) != pubkey || + (value['pubkey'] != null && value['pubkey'] != pubkey)) { + continue; + } + final config = RelayConfig(baseUrl: relay, nsec: nsec); + if (!seen.add(config.baseUrl)) continue; + if (seen.length > 3) return null; + final profile = await fetch(config); + if (profile != null) return profile; + } catch (_) { + // Metadata is optional; never alter keys or block identity selection. + } + } + } + return null; + } + /// Reread and validate the chosen key immediately before create-only adoption. Future adopt( String pubkey, diff --git a/mobile/lib/shared/hosted/hosted_environment.dart b/mobile/lib/shared/hosted/hosted_environment.dart index a3ca701e53..f0979342a2 100644 --- a/mobile/lib/shared/hosted/hosted_environment.dart +++ b/mobile/lib/shared/hosted/hosted_environment.dart @@ -10,6 +10,23 @@ abstract final class HostedEnvironment { 'https://app.builderlabstaging.xyz', }; + /// Shortens hostname fallbacks for display without changing relay identity. + static String communityDisplayName(String name, String relayUrl) { + final relay = Uri.tryParse(relayUrl); + if (relay == null || + name != relay.host || + accountOriginFor(relay) == null) { + return name; + } + for (final domain in _communityAccounts.keys) { + final suffix = '.$domain'; + if (name.endsWith(suffix)) { + return name.substring(0, name.length - suffix.length); + } + } + return name; + } + /// Creation and admission use the same trusted build-specific host mapping. static String get communitySuffix => _communityAccounts.entries .singleWhere((entry) => Uri.parse(entry.value) == origin) diff --git a/mobile/lib/shared/hosted/hosted_relay_auth.dart b/mobile/lib/shared/hosted/hosted_relay_auth.dart index dee75c032d..851e4d7d06 100644 --- a/mobile/lib/shared/hosted/hosted_relay_auth.dart +++ b/mobile/lib/shared/hosted/hosted_relay_auth.dart @@ -59,6 +59,22 @@ class HostedRelayAuth extends ChangeNotifier { HostedEnvironment.requiresAuth(Uri.parse(config.baseUrl).host) && HostedEnvironment.supportsRelay(Uri.parse(config.baseUrl)); + /// Headers for optional reads, without starting account work or changing + /// recovery state. Null means protected metadata must wait for active auth. + Map? cachedHeaders(RelayConfig config) { + if (_disposed) return null; + if (!protects(config)) return const {}; + final cached = _cached; + if (cached == null || + cached.relay != Uri.parse(config.wsUrl).replace(path: '').toString() || + cached.pubkey != pubkeyFromNsec(config.nsec) || + !identical(cached.account, account.account) || + !cached.expiry.isAfter(_now())) { + return null; + } + return {'Nostr-Federated-Identity': 'Bearer ${cached.token}'}; + } + Future> headers(RelayConfig config) async { if (!protects(config)) return const {}; final relay = Uri.parse(config.wsUrl).replace(path: '').toString(); diff --git a/mobile/lib/shared/hosted/saved_identity_profile.dart b/mobile/lib/shared/hosted/saved_identity_profile.dart new file mode 100644 index 0000000000..8182e6b447 --- /dev/null +++ b/mobile/lib/shared/hosted/saved_identity_profile.dart @@ -0,0 +1,176 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:collection'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; + +import '../crypto/event_signing.dart'; +import '../profile/profile_event_parser.dart'; +import '../profile/user_profile.dart'; +import '../../features/age_gate/age_signal_provider.dart'; +import '../relay/relay_query_response.dart'; +import '../relay/relay_session.dart'; +import 'device_identity_source.dart'; +import 'hosted_relay_auth.dart'; + +final savedIdentityProfileClientProvider = Provider( + (ref) => http.Client.new, +); + +final _profileQueueProvider = Provider((ref) => _ProfileQueue()); + +/// Fetches the saved identity's own profile without changing the active session. +final savedIdentityProfileProvider = FutureProvider.autoDispose + .family((ref, pubkey) async { + if (ref.watch(ageSignalProvider) == AgeSignalState.restricted) { + return null; + } + final auth = ref.read(hostedRelayAuthProvider); + final createTransport = ref.read(savedIdentityProfileClientProvider); + final slot = ref.read(_profileQueueProvider).request(); + http.Client? transport; + StreamIterator>? chunks; + var active = true; + ref.onDispose(() { + active = false; + slot.cancelQueued(); + if (chunks != null) unawaited(chunks!.cancel()); + transport?.close(); + }); + if (!await slot.ready.future) return null; + try { + if (!active) return null; + return await DeviceIdentitySource().loadProfile(pubkey, (config) async { + if (!active) return null; + // Passive cards must never queue account restore/identity/assertion + // work ahead of reconnect or admission, even while still visible. + final headers = auth.cachedHeaders(config); + if (headers == null) return null; + final uri = Uri.parse(config.baseUrl).resolve('/query'); + final body = utf8.encode( + jsonEncode([ + { + 'kinds': [0], + 'authors': [pubkey], + 'limit': 1, + }, + ]), + ); + final attemptTransport = createTransport(); + transport = attemptTransport; + var requesting = true; + chunks = null; + final client = attemptTransport; + final request = http.Request('POST', uri) + ..followRedirects = false + ..bodyBytes = body + ..headers.addAll({ + ...headers, + 'Content-Type': 'application/json', + 'Authorization': buildNip98AuthHeader( + method: 'POST', + url: uri.toString(), + bodyBytes: body, + nsec: config.nsec, + pubkey: pubkey, + ), + }); + try { + final response = await (() async { + final response = await client.send(request); + if (!active || !requesting) return null; + chunks = StreamIterator(response.stream); + // One kind-0 event should fit comfortably within 256 KiB. + const maxBytes = 256 * 1024; + if (response.statusCode != 200 || + (response.contentLength ?? 0) > maxBytes) { + return null; + } + final bytes = BytesBuilder(copy: false); + while (await chunks!.moveNext()) { + if (!active || !requesting) return null; + final chunk = chunks!.current; + if (chunk.length > maxBytes - bytes.length) return null; + bytes.add(chunk); + } + if (!active || !requesting) return null; + return (bytes.takeBytes(), response.headers['content-type']); + })().timeout(const Duration(seconds: 5)); + if (!active || response == null) return null; + // Retain the queue slot until bounded decoding actually finishes. + return await _decodeProfile(response.$1, response.$2, pubkey); + } finally { + // Retire this attempt before another relay can be tried. + requesting = false; + if (chunks != null) unawaited(chunks!.cancel()); + client.close(); + transport = null; + } + }); + } finally { + slot.release(); + } + }); + +Future _decodeProfile( + Uint8List bytes, + String? contentType, + String pubkey, +) => Isolate.run(() { + // The transport normally inflates HTTP gzip before the byte cap. Reject + // raw gzip here so the shared parser cannot expand a second, uncapped body. + if (bytes.length > 2 && bytes[0] == 0x1f && bytes[1] == 0x8b) return null; + final events = parseRelayQueryEvents(bytes, contentType); + for (final event in events) { + if (event.kind != 0 || event.pubkey != pubkey) continue; + verifiedNostrEventFromJson(jsonEncode(event.toJson())); + return parseProfileEvent(event).profile; + } + return null; +}); + +// Shared across identities in one provider container. Cancelled queued cards +// never reach secure storage, HTTP, or decoding. +class _ProfileQueue { + final _waiting = Queue<_ProfileSlot>(); + int _running = 0; + + _ProfileSlot request() { + final slot = _ProfileSlot(this); + _waiting.add(slot); + _drain(); + return slot; + } + + void _drain() { + while (_running < 3 && _waiting.isNotEmpty) { + final slot = _waiting.removeFirst(); + _running++; + slot.ready.complete(true); + } + } +} + +class _ProfileSlot { + _ProfileSlot(this.queue); + final _ProfileQueue queue; + final ready = Completer(); + bool _released = false; + + void cancelQueued() { + if (ready.isCompleted) return; + queue._waiting.remove(this); + ready.complete(false); + _released = true; + } + + void release() { + if (_released) return; + _released = true; + queue._running--; + queue._drain(); + } +} diff --git a/mobile/lib/shared/identity_transfer/nsec_import_sheet.dart b/mobile/lib/shared/identity_transfer/nsec_import_sheet.dart index 10c91eedb1..0103c1f7e8 100644 --- a/mobile/lib/shared/identity_transfer/nsec_import_sheet.dart +++ b/mobile/lib/shared/identity_transfer/nsec_import_sheet.dart @@ -154,8 +154,8 @@ class NsecImportSheet extends HookWidget { }, ), const SizedBox(height: Grid.xs), - OutlinedButton( - style: OutlinedButton.styleFrom( + TextButton( + style: TextButton.styleFrom( minimumSize: const Size.fromHeight(52), shape: const StadiumBorder(), ), diff --git a/mobile/lib/shared/relay/media_image.dart b/mobile/lib/shared/relay/media_image.dart index 857fbcd663..019873da61 100644 --- a/mobile/lib/shared/relay/media_image.dart +++ b/mobile/lib/shared/relay/media_image.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; @@ -59,6 +60,9 @@ class MediaImageProvider extends ImageProvider { this.requestTimeout = const Duration(seconds: 30), }); + /// Maximum encoded image body retained before decoding (20 MiB). + static const maxResponseBytes = 20 * 1024 * 1024; + static const _defaultCooldown = Duration(seconds: 30); static const _maxRetryAfter = Duration(minutes: 5); static final Map _cooldownUntil = {}; @@ -105,15 +109,32 @@ class MediaImageProvider extends ImageProvider { try { final abort = Completer(); final deadline = Timer(requestTimeout, abort.complete); + StreamIterator>? body; try { final request = http.AbortableRequest( 'GET', uri, abortTrigger: abort.future, )..headers.addAll(auth.headersFor(url)); - response = await http.Response.fromStream(await client.send(request)); + final streamed = await client.send(request); + final iterator = body = StreamIterator(streamed.stream); + final bytes = BytesBuilder(copy: false); + while (await iterator.moveNext()) { + final chunk = iterator.current; + if (chunk.length > maxResponseBytes - bytes.length) { + throw const FormatException('Image response too large'); + } + bytes.add(chunk); + } + response = http.Response.bytes( + bytes.takeBytes(), + streamed.statusCode, + headers: streamed.headers, + ); } finally { deadline.cancel(); + if (!abort.isCompleted) abort.complete(); + unawaited(body?.cancel()); } } catch (_) { _cooldownUntil[url] = debugNow().add(_defaultCooldown); diff --git a/mobile/lib/shared/widgets/onboarding_loading_page.dart b/mobile/lib/shared/widgets/onboarding_loading_page.dart index aa0797001e..30c733d73d 100644 --- a/mobile/lib/shared/widgets/onboarding_loading_page.dart +++ b/mobile/lib/shared/widgets/onboarding_loading_page.dart @@ -13,7 +13,7 @@ class OnboardingLoadingPage extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = utilitySurfaceThemeData(Theme.of(context)); + final theme = Theme.of(context); final safe = MediaQuery.paddingOf(context); return PopScope( canPop: false, diff --git a/mobile/lib/shared/widgets/onboarding_route.dart b/mobile/lib/shared/widgets/onboarding_route.dart new file mode 100644 index 0000000000..229dc82ce7 --- /dev/null +++ b/mobile/lib/shared/widgets/onboarding_route.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +/// A standard onboarding route that skips motion when the OS requests it. +Route onboardingRoute( + BuildContext context, { + required WidgetBuilder builder, + RouteSettings? settings, +}) { + if (MediaQuery.disableAnimationsOf(context)) { + return PageRouteBuilder( + settings: settings, + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (context, _, _) => Semantics( + scopesRoute: true, + explicitChildNodes: true, + child: builder(context), + ), + ); + } + return MaterialPageRoute(settings: settings, builder: builder); +} + +/// Declarative counterpart of [onboardingRoute] for nested onboarding steps. +class OnboardingPage extends MaterialPage { + const OnboardingPage({super.key, required super.child}); + + @override + Route createRoute(BuildContext context) { + if (!MediaQuery.disableAnimationsOf(context)) { + return super.createRoute(context); + } + return _InstantOnboardingRoute(this); + } +} + +class _InstantOnboardingRoute extends PageRouteBuilder { + _InstantOnboardingRoute(OnboardingPage page) + : super( + settings: page, + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (_, _, _) => const SizedBox.shrink(), + ); + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) => Semantics( + scopesRoute: true, + explicitChildNodes: true, + child: (settings as OnboardingPage).child, + ); +} diff --git a/mobile/lib/shared/widgets/onboarding_step_page.dart b/mobile/lib/shared/widgets/onboarding_step_page.dart index c4fcd668b9..17df866ca3 100644 --- a/mobile/lib/shared/widgets/onboarding_step_page.dart +++ b/mobile/lib/shared/widgets/onboarding_step_page.dart @@ -27,28 +27,35 @@ class OnboardingStepPage extends StatelessWidget { final VoidCallback? onConnectionOptions; final double foregroundProgress; final double keyboardBottomSpacing; - final VoidCallback onBack; + final VoidCallback? onBack; final Widget content; final List actions; @override Widget build(BuildContext context) { - final theme = utilitySurfaceThemeData(Theme.of(context)); + final theme = Theme.of(context); final ios = defaultTargetPlatform == TargetPlatform.iOS; final buttonStyle = FilledButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, minimumSize: const Size.fromHeight(52), shape: const StadiumBorder(), ); return Theme( data: theme.copyWith( filledButtonTheme: FilledButtonThemeData(style: buttonStyle), - outlinedButtonTheme: OutlinedButtonThemeData(style: buttonStyle), - textButtonTheme: TextButtonThemeData(style: buttonStyle), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: theme.colorScheme.onSurface, + minimumSize: const Size.fromHeight(52), + shape: const StadiumBorder(), + ), + ), ), child: PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { - if (!didPop) onBack(); + if (!didPop) onBack?.call(); }, child: Scaffold( backgroundColor: theme.colorScheme.surface, @@ -89,14 +96,16 @@ class OnboardingStepPage extends StatelessWidget { ).compactHeight, child: IosNavigationBar( title: '', - leading: IosNavigationAction( - label: 'Back', - symbol: 'chevron.backward', - onPressed: onBack, - ), + leading: onBack == null + ? null + : IosNavigationAction( + label: 'Back', + symbol: 'chevron.backward', + onPressed: onBack, + ), ), ) - else + else if (onBack != null) Align( alignment: AlignmentDirectional.centerStart, child: Padding( diff --git a/mobile/test/features/onboarding/fullscreen_onboarding_test.dart b/mobile/test/features/onboarding/fullscreen_onboarding_test.dart index 2f52c4755e..d014fc7cd5 100644 --- a/mobile/test/features/onboarding/fullscreen_onboarding_test.dart +++ b/mobile/test/features/onboarding/fullscreen_onboarding_test.dart @@ -165,7 +165,7 @@ void main() { await tester.tap(find.byKey(const Key('community-switcher-add'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); - expect(find.byTooltip('Back'), findsNothing); + expect(find.byTooltip('Back').hitTestable(), findsNothing); expect(find.byType(BeeLoadingMark), findsOneWidget); await tester.tap(find.text('Cancel')); await tester.pump(); @@ -230,11 +230,16 @@ void main() { fixture.beforeAdmission = (_) => resumedAdmission.future; await tester.tap(find.byType(CommunityGridOption).last); await tester.pump(); - await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(); + await tester.pump( + ModalRoute.of( + tester.element(find.text('Joining your community')), + )!.transitionDuration, + ); expect(find.text('Home content'), findsNothing); expect(find.text('Joining your community'), findsOneWidget); expect(find.byType(CircularProgressIndicator), findsNothing); - expect(find.byTooltip('Back'), findsNothing); + expect(find.byTooltip('Back').hitTestable(), findsNothing); expect( tester.getBottomLeft(find.byType(BeeLoadingMark)).dy, lessThan(tester.getTopLeft(find.text('Joining your community')).dy), diff --git a/mobile/test/features/onboarding/hosted_communities_test.dart b/mobile/test/features/onboarding/hosted_communities_test.dart index f08c1cd656..36e4cc6e26 100644 --- a/mobile/test/features/onboarding/hosted_communities_test.dart +++ b/mobile/test/features/onboarding/hosted_communities_test.dart @@ -316,7 +316,7 @@ void main() { expect( find.descendant( of: find.byType(CommunityGridOption), - matching: find.text('two.communities.buzz.xyz'), + matching: find.text('two'), ), findsWidgets, ); diff --git a/mobile/test/features/onboarding/hosted_community_labels_test.dart b/mobile/test/features/onboarding/hosted_community_labels_test.dart new file mode 100644 index 0000000000..27144531de --- /dev/null +++ b/mobile/test/features/onboarding/hosted_community_labels_test.dart @@ -0,0 +1,57 @@ +import 'package:buzz/features/onboarding/hosted_community_selection.dart'; +import 'package:buzz/shared/community/community_grid_option.dart'; +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:flutter/material.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('picker shortens hosted fallback labels and preserves titles', ( + tester, + ) async { + FlutterSecureStorage.setMockInitialValues({}); + final fixture = HostedCommunitiesFixture(); + await fixture.initialize(widget: true); + addTearDown(fixture.dispose); + + for (final (host, name, expected) in [ + ('mobile.communities.buzz.xyz', 'mobile.communities.buzz.xyz', 'mobile'), + ( + 'mobile.communities.public.builderlabstaging.xyz', + 'mobile.communities.public.builderlabstaging.xyz', + 'mobile', + ), + ('mobile.communities.buzz.xyz', 'Mobile Team', 'Mobile Team'), + ('chat.example.com', 'chat.example.com', 'chat.example.com'), + ( + 'mobile.communities.buzz.xyz.example.com', + 'mobile.communities.buzz.xyz.example.com', + 'mobile.communities.buzz.xyz.example.com', + ), + ('communities.buzz.xyz', 'communities.buzz.xyz', 'communities.buzz.xyz'), + ]) { + final relay = 'wss://$host'; + fixture.flow.communities = [ + AddCommunityDeepLink(relayUrl: relay, name: name), + ]; + await tester.pumpWidget( + WidgetHelpers.testable( + child: HostedCommunitySelection(flow: fixture.flow, fullScreen: true), + ), + ); + await tester.pumpAndSettle(); + expect(find.text(expected), findsOneWidget); + expect(find.bySemanticsLabel(expected), findsOneWidget); + final tile = tester.widget( + find.byType(CommunityGridOption), + ); + expect(tile.relayUrl, relay); + expect(fixture.flow.communities.single.name, name); + if (name != expected) expect(find.text(name), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + } + }, semanticsEnabled: true); +} diff --git a/mobile/test/features/onboarding/hosted_journeys_test.dart b/mobile/test/features/onboarding/hosted_journeys_test.dart index 7563d9e0bc..c1f1e317ff 100644 --- a/mobile/test/features/onboarding/hosted_journeys_test.dart +++ b/mobile/test/features/onboarding/hosted_journeys_test.dart @@ -549,7 +549,7 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 400)); try { - expect(find.byTooltip('Back'), findsNothing); + expect(find.byTooltip('Back').hitTestable(), findsNothing); // External teardown still fences pending enrollment. await f.flow.leave(); await tester.pump(); @@ -611,7 +611,7 @@ void main() { of: find.byType(TextField).hitTestable(), matching: find.byType(BottomSheet), ), - findsOneWidget, + findsNothing, ); expect(f.sent('/claim'), hasLength(1)); expect(f.recoveryScopes, hasLength(3)); @@ -911,10 +911,10 @@ void main() { await f.tap(tester, 'Continue'); // A refused key must not pin the URL against an explicit allowed choice. f.refusedSelfHostedPubkey = f.keys.public; - await f.tap(tester, fullNpub(f.keys.public)!); + await f.tap(tester, shortPubkey(f.keys.public)); expect(await f.storage.loadAll(), isEmpty); expect(find.textContaining('Could not join.'), findsOneWidget); - await f.tap(tester, fullNpub(f.secondKeys.public)!); + await f.tap(tester, shortPubkey(f.secondKeys.public)); expect((await f.storage.loadAll()).single.nsec, f.secondKeys.nsec); expect( (await AccountIdentityStore().forPairing(f.keys.public).read())!.pubkey, diff --git a/mobile/test/features/onboarding/onboarding_entry_routes_test.dart b/mobile/test/features/onboarding/onboarding_entry_routes_test.dart index c27a84b888..00e4f9e993 100644 --- a/mobile/test/features/onboarding/onboarding_entry_routes_test.dart +++ b/mobile/test/features/onboarding/onboarding_entry_routes_test.dart @@ -116,7 +116,9 @@ void main() { ); await tester.tap(find.text('Connect').hitTestable()); await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); + // Finish the standard page push before checking the loading surface. + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); } if (HostedEnvironment.authDomains.isNotEmpty) { @@ -154,7 +156,7 @@ void main() { find.byType(CommunityGridOption).hitTestable().last, ) .name, - 'two.communities.buzz.xyz', + 'two', ); f.beforeAdmission = null; await tester.tap(find.byType(CommunityGridOption).first); @@ -290,7 +292,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(SelfHostedEntrySheet), findsOneWidget); expect(find.byType(NsecImportSheet), findsNothing); - expect(find.text('Join community'), findsOneWidget); + expect(find.text('You already have a saved identity'), findsOneWidget); expect( tester.getSize(find.byType(SelfHostedEntrySheet)).height, joinHeight, @@ -315,7 +317,7 @@ void main() { // Completion races the outgoing route animation. pendingProbe.complete(); await tester.pumpAndSettle(); - expect(find.text('Join community'), findsOneWidget); + expect(find.text('You already have a saved identity'), findsOneWidget); expect(await container.read(communityStorageProvider).loadAll(), isEmpty); pendingProbe = null; await tester.tap(find.text('Import a Nostr key').hitTestable()); diff --git a/mobile/test/features/onboarding/onboarding_reduced_motion_test.dart b/mobile/test/features/onboarding/onboarding_reduced_motion_test.dart new file mode 100644 index 0000000000..c46855fed3 --- /dev/null +++ b/mobile/test/features/onboarding/onboarding_reduced_motion_test.dart @@ -0,0 +1,129 @@ +import 'package:buzz/features/onboarding/hosted_connect_page.dart'; +import 'package:buzz/features/pairing/add_community_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../helpers/hosted_communities_fixture.dart'; + +void main() { + testWidgets('reduced motion swaps Add and hosted steps instantly', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + void expectRouteScopes(Finder content, int count) { + var scopes = 0; + SemanticsNode? node = tester.getSemantics(content); + while (node != null) { + if (node.flagsCollection.scopesRoute) scopes++; + node = node.parent; + } + expect(scopes, count); + } + + final pair = ValueNotifier(false); + addTearDown(pair.dispose); + Widget app(Widget home, {List observers = const []}) => + ProviderScope( + child: MaterialApp( + navigatorObservers: observers, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(disableAnimations: true), + child: child!, + ), + home: home, + ), + ); + await tester.pumpWidget( + app( + ValueListenableBuilder( + valueListenable: pair, + builder: (_, pairing, _) => AddCommunityPage( + existingUser: pairing, + onBack: () => pair.value = false, + onCommunityLink: () {}, + onPairing: () => pair.value = true, + onScan: () {}, + onOpenCommunity: (_) {}, + onSubmitPairing: (_) {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + pair.value = true; + await tester.pump(); + await tester.pump(); + final pairRoute = ModalRoute.of( + tester.element(find.byType(TextField).first), + )!; + expect(pairRoute.animation!.isCompleted, isTrue); + expect(pairRoute.transitionDuration, Duration.zero); + expect(find.text('Add a community'), findsNothing); + expectRouteScopes(find.byType(TextField).first, 2); + pair.value = false; + await tester.pump(); + await tester.pump(); + expect(find.text('Add a community'), findsOneWidget); + expect(find.byKey(const Key('community-pairing-input')), findsNothing); + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + + FlutterSecureStorage.setMockInitialValues({}); + final f = HostedCommunitiesFixture(); + await f.initialize(widget: true); + addTearDown(f.dispose); + f.rows = [f.row('one'), f.row('two')]; + final observer = _Routes(); + var closes = 0; + await tester.pumpWidget( + app( + HostedConnectPage( + flow: f.flow, + onClose: () => closes++, + profileBuilder: (_) => const Text('Profile content'), + ), + observers: [observer], + ), + ); + await tester.pump(); + await tester.pump(); + final outer = + observer.routes.singleWhere((r) => r.settings.name == 'hosted-setup') + as TransitionRoute; + expect(outer.transitionDuration, Duration.zero); + expect(outer.animation!.isCompleted, isTrue); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(closes, 1); + await f.flow.signInAccount(); + await tester.pump(); + await tester.pump(); + final title = find.text('Select a community'); + expect(title, findsOneWidget); + // The imperative hosted route and its declarative step each own a scope. + expectRouteScopes(title, 2); + final inner = ModalRoute.of(tester.element(title))!; + expect(inner.transitionDuration, Duration.zero); + expect(inner.animation!.isCompleted, isTrue); + // Profile uses the same page key; updated child content must still rebuild. + final profile = f.flow.requestProfile((_) async {}); + await tester.pump(); + await tester.pump(); + expect(find.text('Profile content'), findsOneWidget); + f.flow.finishProfile(); + await profile; + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + semantics.dispose(); + }); +} + +class _Routes extends NavigatorObserver { + final routes = >[]; + @override + void didPush(Route route, Route? previousRoute) => + routes.add(route); +} diff --git a/mobile/test/features/onboarding/saved_identity_card_test.dart b/mobile/test/features/onboarding/saved_identity_card_test.dart new file mode 100644 index 0000000000..f6e295e76f --- /dev/null +++ b/mobile/test/features/onboarding/saved_identity_card_test.dart @@ -0,0 +1,156 @@ +import 'dart:convert'; +import 'dart:async'; + +import 'package:buzz/features/onboarding/self_hosted_entry_sheet.dart'; +import 'package:buzz/features/onboarding/saved_identity_card.dart'; +import 'package:buzz/shared/hosted/self_hosted_entry.dart'; +import 'package:buzz/shared/hosted/device_identity_source.dart'; +import 'package:buzz/shared/hosted/saved_identity_profile.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/utils/string_utils.dart'; +import 'package:buzz/shared/widgets/avatar_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../../helpers/widget_helpers.dart'; + +void main() { + testWidgets('loads name and avatar when the active profile cache is empty', ( + tester, + ) async { + const pubkey = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + final profile = Completer(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + savedIdentityProfileProvider( + pubkey, + ).overrideWith((ref) => profile.future), + ], + child: SavedIdentityCard( + identity: const DeviceIdentityChoice(pubkey, 'Home'), + onSelect: () {}, + ), + ), + ); + expect(find.text('Saved identity'), findsOneWidget); + expect(find.text('Used in Home'), findsNothing); + profile.complete( + const UserProfile( + pubkey: pubkey, + displayName: 'Kenny', + avatarUrl: 'https://home.example/avatar.png', + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Kenny'), findsOneWidget); + expect(find.text('Saved identity'), findsNothing); + expect( + tester.widget(find.byType(AvatarImage)).imageUrl, + 'https://home.example/avatar.png', + ); + }); + + testWidgets('identity card below subtitle joins with the selected key', ( + tester, + ) async { + final keys = nostr.Keys('1'.padLeft(64, '0')); + const source = 'mobile.communities.public.builderlabstaging.xyz'; + FlutterSecureStorage.setMockInitialValues({ + 'buzz_communities': jsonEncode([ + {'name': source, 'pubkey': keys.public, 'nsec': keys.nsec}, + ]), + }); + String? attemptedKey; + final admission = Completer(); + await tester.binding.setSurfaceSize(const Size(393, 852)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + selfHostedEntryProvider.overrideWithValue( + SelfHostedEntry( + probe: (community) async { + attemptedKey = community.pubkey; + await admission.future; + throw StateError('Offline'); + }, + ), + ), + ], + child: const SelfHostedEntrySheet( + relay: 'wss://honeycombstudios.example', + name: 'honeycombstudios.example', + fullScreen: true, + ), + ), + ); + await tester.pumpAndSettle(); + expect( + find.textContaining('Join honeycombstudios.example.'), + findsOneWidget, + ); + expect(find.text('You already have a saved identity'), findsOneWidget); + expect(find.text('Used in mobile'), findsNothing); + expect(find.text(shortPubkey(keys.public)), findsOneWidget); + expect(find.text(fullNpub(keys.public)!), findsNothing); + expect( + find.bySemanticsLabel('Use Saved identity, ${shortPubkey(keys.public)}'), + findsOneWidget, + ); + + expect(find.text('View identity details'), findsNothing); + expect( + tester.getBottomRight(find.text('Import a Nostr key')).dy, + greaterThan(750), + ); + expect( + tester.getTopLeft(find.byType(SavedIdentityCard)).dy - + tester + .getBottomLeft( + find.textContaining('Use this identity or import a Nostr key.'), + ) + .dy, + 24, + ); + + final container = ProviderScope.containerOf( + tester.element(find.byType(SelfHostedEntrySheet)), + ); + container + .read(userCacheProvider.notifier) + .put( + UserProfile( + pubkey: keys.public, + displayName: 'Alex', + avatarUrl: 'https://example.com/avatar.png', + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Alex'), findsOneWidget); + expect(find.text('Used in mobile'), findsNothing); + expect( + tester.widget(find.byType(AvatarImage)).imageUrl, + 'https://example.com/avatar.png', + ); + await tester.tap(find.text('Alex')); + await tester.pump(); + expect(find.text('Getting ready'), findsOneWidget); + expect(find.text('You already have a saved identity'), findsNothing); + admission.complete(); + await tester.pumpAndSettle(); + expect(attemptedKey, keys.public); + expect( + find.textContaining('Your saved keys have been kept.'), + findsOneWidget, + ); + await tester.tap(find.text('Import a Nostr key')); + await tester.pumpAndSettle(); + expect(find.text('Import identity'), findsOneWidget); + }); +} diff --git a/mobile/test/features/onboarding/saved_identity_hosted_queue_test.dart b/mobile/test/features/onboarding/saved_identity_hosted_queue_test.dart new file mode 100644 index 0000000000..0ca3ed0dee --- /dev/null +++ b/mobile/test/features/onboarding/saved_identity_hosted_queue_test.dart @@ -0,0 +1,147 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/features/onboarding/self_hosted_entry_sheet.dart'; +import 'package:buzz/features/onboarding/saved_identity_card.dart'; +import 'package:buzz/shared/hosted/saved_identity_profile.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/hosted_relay_auth.dart'; +import 'package:buzz/shared/hosted/hosted_environment.dart'; +import 'package:buzz/shared/relay/relay_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + testWidgets( + 'hosted chooser never queues passive account work', + (tester) async { + final keys = [ + for (var i = 1; i <= 4; i++) + nostr.Keys(i.toRadixString(16).padLeft(64, '0')), + ]; + final relay = 'wss://saved.${HostedEnvironment.communitySuffix}'; + FlutterSecureStorage.setMockInitialValues({ + 'buzz_communities': jsonEncode([ + for (final key in keys) + { + 'name': 'Saved', + 'relayUrl': relay, + 'pubkey': key.public, + 'nsec': key.nsec, + }, + ]), + }); + final release = Completer(); + final started = Completer(); + final paths = []; + final expiry = DateTime.now() + .add(const Duration(hours: 1)) + .toIso8601String(); + final account = BuilderlabAccountClient( + httpClient: MockClient((request) async { + final path = request.url.path; + paths.add(path); + if (path.endsWith('/known-communities/list')) { + started.complete(); + await release.future; + } + final value = path.endsWith('/login/exchange') + ? {'session_credential': 'test-session', 'expires_at': expiry} + : path.endsWith('/auth/me') + ? {'subject': 'test-subject', 'expires_at': expiry} + : path.endsWith('/nostr-identities/current') + ? { + 'identity': {'pubkey_hex': keys.first.public}, + } + : path.endsWith('/identity/assertions') + ? { + 'assertion': 'test-assertion', + 'nostr_pubkey': keys.first.public, + 'expires_at': + DateTime.now().millisecondsSinceEpoch ~/ 1000 + 120, + } + : {}; + return http.Response(jsonEncode(value), 200); + }), + ); + addTearDown(account.dispose); + await account.exchange(code: 'code', verifier: 'v' * 43); + paths.clear(); + final busy = account.post('/v1/buzz/known-communities/list', {}); + await started.future; + final container = ProviderContainer( + overrides: [ + builderlabAccountProvider.overrideWithValue(account), + savedIdentityProfileClientProvider.overrideWithValue( + () => MockClient((_) async { + fail('Uncached hosted cards must not send relay requests'); + }), + ), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + home: SelfHostedEntrySheet( + relay: 'wss://join.example', + name: 'Join', + fullScreen: true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(SavedIdentityCard), findsNWidgets(4)); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const SizedBox(), + ), + ); + await tester.pumpAndSettle(); + final active = account.post('/v1/buzz/known-communities/add', {}); + release.complete(); + await busy; + await active; + await tester.pumpAndSettle(); + expect(paths.map((path) => path.split('/').last), ['list', 'add']); + + // Active authentication may populate the cache; passive reads use it only + // for the same relay, key, account session, and unexpired lifetime. + var now = DateTime.now(); + final auth = HostedRelayAuth(account, now: () => now); + addTearDown(auth.dispose); + final config = RelayConfig(baseUrl: relay, nsec: keys.first.nsec); + expect(auth.cachedHeaders(config), isNull); + final headers = await auth.headers(config); + final count = paths.length; + expect(auth.cachedHeaders(config), headers); + expect( + auth.cachedHeaders(RelayConfig(baseUrl: relay, nsec: keys.last.nsec)), + isNull, + ); + expect( + auth.cachedHeaders( + RelayConfig( + baseUrl: 'wss://other.${HostedEnvironment.communitySuffix}', + nsec: keys.first.nsec, + ), + ), + isNull, + ); + now = now.add(const Duration(minutes: 5)); + expect(auth.cachedHeaders(config), isNull); + expect(paths.length, count); + }, + skip: + !const bool.fromEnvironment('BUZZ_HOSTED_RELAY_AUTH') || + !HostedEnvironment.enabled, + ); +} diff --git a/mobile/test/features/onboarding/saved_identity_queue_test.dart b/mobile/test/features/onboarding/saved_identity_queue_test.dart new file mode 100644 index 0000000000..17511addba --- /dev/null +++ b/mobile/test/features/onboarding/saved_identity_queue_test.dart @@ -0,0 +1,111 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/features/onboarding/self_hosted_entry_sheet.dart'; +import 'package:buzz/features/onboarding/saved_identity_card.dart'; +import 'package:buzz/shared/hosted/saved_identity_profile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + testWidgets('many saved identities share a cancellable lookup queue', ( + tester, + ) async { + final keys = [ + for (var i = 1; i <= 12; i++) + nostr.Keys(i.toRadixString(16).padLeft(64, '0')), + ]; + FlutterSecureStorage.setMockInitialValues({ + 'buzz_communities': jsonEncode([ + for (final key in keys) + { + 'name': 'Home', + 'relayUrl': 'wss://home.example', + 'pubkey': key.public, + 'nsec': key.nsec, + }, + ]), + }); + final clients = <_PendingProfileClient>[]; + final container = ProviderContainer( + overrides: [ + savedIdentityProfileClientProvider.overrideWithValue(() { + final client = _PendingProfileClient(); + clients.add(client); + return client; + }), + ], + ); + addTearDown(container.dispose); + Widget chooser() => UncontrolledProviderScope( + container: container, + child: MaterialApp( + home: const SelfHostedEntrySheet( + relay: 'wss://join.example', + name: 'Join', + fullScreen: true, + ), + ), + ); + await tester.pumpWidget(chooser()); + await tester.pumpAndSettle(); + expect(find.byType(SavedIdentityCard), findsNWidgets(12)); + expect(clients, hasLength(3)); + // Completing one lookup admits exactly one queued identity. + clients.first.body.add(List.filled(256 * 1024 + 1, 32)); + await tester.pumpAndSettle(); + expect(clients, hasLength(4)); + expect(clients.first.closed, isTrue); + // Leave while other requests and queued cards still exist. + await tester.pumpWidget( + UncontrolledProviderScope(container: container, child: const SizedBox()), + ); + await tester.pumpAndSettle(); + expect(clients, hasLength(4)); + expect( + clients.every((client) => client.closed && client.cancelled), + isTrue, + ); + await tester.pump(const Duration(seconds: 6)); + expect(clients, hasLength(4)); + // Reopening uses fresh work; cancelled slots cannot starve the chooser. + await tester.pumpWidget(chooser()); + await tester.pumpAndSettle(); + expect(clients, hasLength(7)); + await tester.pumpWidget( + UncontrolledProviderScope(container: container, child: const SizedBox()), + ); + await tester.pumpAndSettle(); + expect( + clients.every((client) => client.closed && client.cancelled), + isTrue, + ); + }); +} + +class _PendingProfileClient extends http.BaseClient { + _PendingProfileClient() { + body = StreamController>( + onCancel: () { + cancelled = true; + }, + ); + } + late final StreamController> body; + bool closed = false; + bool cancelled = false; + + @override + Future send(http.BaseRequest request) async => + http.StreamedResponse(body.stream, 200); + + @override + void close() { + closed = true; + unawaited(body.close()); + } +} diff --git a/mobile/test/features/profile/profile_setup_fallback_test.dart b/mobile/test/features/profile/profile_setup_fallback_test.dart index d5eae4b69c..96909f7658 100644 --- a/mobile/test/features/profile/profile_setup_fallback_test.dart +++ b/mobile/test/features/profile/profile_setup_fallback_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'package:buzz/shared/hosted/hosted_flow.dart'; import 'package:buzz/features/profile/profile_setup_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -15,18 +17,39 @@ void main() { await fixture.initialize(widget: true); addTearDown(fixture.dispose); var attempts = 0; + final release = Completer(); final pending = fixture.flow.requestProfile((_) async { attempts++; + await release.future; throw StateError('Relay unavailable'); }); await tester.pumpWidget( - WidgetHelpers.testable(child: ProfileSetupPage(flow: fixture.flow)), + WidgetHelpers.testable( + disableAnimations: true, + child: ProfileSetupPage(flow: fixture.flow), + ), ); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(); + final route = ModalRoute.of(tester.element(find.byType(TextField)))!; + expect(route.transitionDuration, Duration.zero); + expect(route.reverseTransitionDuration, Duration.zero); + expect(route.animation!.isCompleted, isTrue); expect(find.text('Not now'), findsNothing); + expect(find.byTooltip('Back'), findsNothing); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(fixture.flow.stage, HostedFlowStage.profile); await tester.enterText(find.byType(TextField), 'Kenny'); await tester.pump(); await tester.tap(find.text('Continue')); + await tester.pump(); + expect(find.text('Saving…'), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(fixture.flow.stage, HostedFlowStage.profile); + expect(find.text('Saving…'), findsOneWidget); + release.complete(); await tester.pumpAndSettle(); expect(attempts, 1); expect(find.text('Not now'), findsOneWidget); diff --git a/mobile/test/shared/hosted/saved_identity_profile_test.dart b/mobile/test/shared/hosted/saved_identity_profile_test.dart new file mode 100644 index 0000000000..df396819db --- /dev/null +++ b/mobile/test/shared/hosted/saved_identity_profile_test.dart @@ -0,0 +1,255 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:buzz/shared/crypto/event_signing.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/hosted_relay_auth.dart'; +import 'package:buzz/shared/hosted/hosted_environment.dart'; +import 'package:buzz/shared/relay/relay_provider.dart'; +import 'package:buzz/shared/hosted/saved_identity_profile.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final secret = '1'.padLeft(64, '0'); + final keys = nostr.Keys(secret); + final other = nostr.Keys('2'.padLeft(64, '0')); + + test( + 'inactive profile lookup avoids account work and preserves reconnect recovery', + () async { + final account = _SignedOutAccount(); + final container = ProviderContainer( + overrides: [ + builderlabAccountProvider.overrideWithValue(account), + savedIdentityProfileClientProvider.overrideWithValue( + () => MockClient((_) async { + fail('Signed-out hosted lookup must not reach the relay'); + }), + ), + ], + ); + addTearDown(container.dispose); + addTearDown(account.dispose); + final active = RelayConfig( + baseUrl: 'wss://active.${HostedEnvironment.communitySuffix}', + nsec: keys.nsec, + ); + final inactive = 'wss://inactive.${HostedEnvironment.communitySuffix}'; + FlutterSecureStorage.setMockInitialValues({ + 'buzz_communities': jsonEncode([ + { + 'name': 'Inactive', + 'relayUrl': inactive, + 'pubkey': other.public, + 'nsec': other.nsec, + }, + ]), + }); + final auth = container.read(hostedRelayAuthProvider); + expect(auth.protects(active), isTrue); + await expectLater( + auth.headers(active), + throwsA(AccountFailure.signedOut), + ); + final previous = (auth.failedRelay, auth.failedPubkey, auth.failure); + var notifications = 0; + auth.addListener(() { + notifications++; + }); + expect( + await container.read(savedIdentityProfileProvider(other.public).future), + isNull, + ); + expect(account.restores, 1); + expect((auth.failedRelay, auth.failedPubkey, auth.failure), previous); + expect(notifications, 0); + }, + skip: + !const bool.fromEnvironment('BUZZ_HOSTED_RELAY_AUTH') || + !HostedEnvironment.enabled, + ); + + for (final failure in ['oversized', 'stalled body', 'stalled headers']) { + testWidgets('closes $failure before trying another saved relay', ( + tester, + ) async { + FlutterSecureStorage.setMockInitialValues({ + 'buzz_communities': jsonEncode([ + for (final host in ['broken.example', 'next.example']) + { + 'name': host, + 'relayUrl': 'wss://$host', + 'pubkey': keys.public, + 'nsec': keys.nsec, + }, + ]), + }); + var cancelled = false; + bool? closedBeforeFallback; + bool? cancelledBeforeFallback; + final body = StreamController>( + onCancel: () { + cancelled = true; + }, + ); + final clients = <_ProfileClient>[]; + final container = ProviderContainer( + overrides: [ + savedIdentityProfileClientProvider.overrideWithValue(() { + final client = _ProfileClient((request) async { + if (request.url.host == 'next.example') { + closedBeforeFallback = clients.first.closed; + cancelledBeforeFallback = cancelled; + return http.StreamedResponse( + Stream.value(utf8.encode('[]')), + 503, + ); + } + if (failure == 'stalled headers') { + return Completer().future; + } + if (failure == 'oversized') { + body.add(List.filled(256 * 1024, 32)); + body.add([32]); + } + return http.StreamedResponse(body.stream, 200); + }); + clients.add(client); + return client; + }), + ], + ); + addTearDown(container.dispose); + final subscription = container.listen( + savedIdentityProfileProvider(keys.public), + (_, _) {}, + ); + addTearDown(subscription.close); + var finished = false; + container.read(savedIdentityProfileProvider(keys.public).future).then(( + value, + ) { + expectSync(value, isNull); + finished = true; + }); + await tester.pump(); + if (failure == 'oversized') expectSync(finished, isTrue); + await tester.pump(const Duration(seconds: 6)); + expectSync(finished, isTrue); + expectSync(clients, hasLength(2)); + expectSync(closedBeforeFallback, isTrue); + if (failure != 'stalled headers') { + expectSync(cancelledBeforeFallback, isTrue); + } + expectSync(clients.every((client) => client.closed), isTrue); + unawaited(body.close()); + }); + } + + for (final variant in ['valid', 'invalid signature', 'raw gzip']) { + final invalid = variant == 'invalid signature'; + test('loads only the saved key profile: $variant', () async { + final records = jsonEncode([ + { + 'name': 'Other', + 'relayUrl': 'wss://other.example', + 'pubkey': other.public, + 'nsec': other.nsec, + }, + { + 'name': 'Unavailable', + 'relayUrl': 'wss://offline.example', + 'pubkey': keys.public, + 'nsec': keys.nsec, + }, + { + 'name': 'Home', + 'relayUrl': 'wss://home.example', + 'pubkey': keys.public, + 'nsec': keys.nsec, + }, + ]); + FlutterSecureStorage.setMockInitialValues({'buzz_communities': records}); + final event = + jsonDecode( + signNostrEvent( + kind: 0, + secretKey: secret, + content: jsonEncode({ + 'name': 'Real name', + 'picture': 'https://home.example/avatar.png', + }), + ).toJson(), + ) + as Map; + if (invalid) event['sig'] = '0' * 128; + final requests = []; + final container = ProviderContainer( + overrides: [ + savedIdentityProfileClientProvider.overrideWithValue( + () => MockClient((request) async { + requests.add(request.url.host); + expect(request.followRedirects, isFalse); + expect(request.headers['Authorization'], startsWith('Nostr ')); + expect(jsonDecode(request.body)[0]['authors'], [keys.public]); + if (request.url.host == 'offline.example') { + return http.Response('', 503); + } + final json = jsonEncode([event]); + if (variant == 'raw gzip') { + return http.Response.bytes(gzip.encode(utf8.encode(json)), 200); + } + return http.Response(json, 200); + }), + ), + ], + ); + addTearDown(container.dispose); + final profile = await container.read( + savedIdentityProfileProvider(keys.public).future, + ); + expect(requests, ['offline.example', 'home.example']); + if (invalid || variant == 'raw gzip') { + expect(profile, isNull); + } else { + expect(profile?.displayName, 'Real name'); + expect(profile?.avatarUrl, 'https://home.example/avatar.png'); + } + expect( + await const FlutterSecureStorage().read(key: 'buzz_communities'), + records, + ); + }); + } +} + +class _ProfileClient extends http.BaseClient { + _ProfileClient(this.respond); + final Future Function(http.BaseRequest) respond; + bool closed = false; + + @override + Future send(http.BaseRequest request) => + respond(request); + + @override + void close() { + closed = true; + } +} + +class _SignedOutAccount extends BuilderlabAccountClient { + int restores = 0; + @override + Future restore() async { + restores++; + return null; + } +} diff --git a/mobile/test/shared/relay/media_image_test.dart b/mobile/test/shared/relay/media_image_test.dart index e3626cf892..c67815a959 100644 --- a/mobile/test/shared/relay/media_image_test.dart +++ b/mobile/test/shared/relay/media_image_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:async'; +import 'dart:typed_data'; import 'package:buzz/shared/relay/media_auth.dart'; import 'package:buzz/shared/relay/media_image.dart'; @@ -111,6 +112,60 @@ void main() { }); group('MediaImageProvider fetching', () { + test( + 'oversized image streams abort before decode and preserve the client', + () async { + for (final declared in [true, false]) { + var cancelled = false; + var aborted = false; + var delivered = false; + final controller = StreamController>( + onCancel: () => cancelled = true, + ); + final client = _StreamingClient((request) async { + if (request.url.path.endsWith('valid.png')) { + return http.StreamedResponse(Stream.value(_pngBytes), 200); + } + (request as http.AbortableRequest).abortTrigger!.then((_) { + aborted = true; + }); + // Never finish: the loader must stop as soon as the cap is crossed. + controller.add(Uint8List(MediaImageProvider.maxResponseBytes)); + controller.add([0]); + return http.StreamedResponse( + controller.stream, + 200, + contentLength: declared + ? MediaImageProvider.maxResponseBytes + 1 + : null, + ); + }); + final provider = MediaImageProvider( + url: 'https://avatar.example/$declared.png', + auth: _auth(), + client: client, + onBytesLoaded: (_) => delivered = true, + ); + final error = await _waitError( + provider.resolve(ImageConfiguration.empty), + ).timeout(const Duration(seconds: 2)); + expect(error, isA()); + expect(cancelled, isTrue); + expect(aborted, isTrue); + expect(delivered, isFalse); + await controller.close(); + await _wait( + MediaImageProvider( + url: 'https://avatar.example/valid.png', + auth: _auth(), + client: client, + ).resolve(ImageConfiguration.empty), + ); + PaintingBinding.instance.imageCache.clear(); + } + }, + ); + test('resolves one fetch for repeated resolves of the same key', () async { var fetches = 0; final client = http_testing.MockClient((request) async { @@ -242,3 +297,11 @@ Future _waitError(ImageStream stream) async { return e; } } + +class _StreamingClient extends http.BaseClient { + _StreamingClient(this.respond); + final Future Function(http.BaseRequest) respond; + @override + Future send(http.BaseRequest request) => + respond(request); +}