Clarify mobile community onboarding and saved identity selection (#8729)

## Summary

Make community onboarding easier to scan: shorten hosted hostname
fallbacks, clarify Add Community instructions, and replace raw saved-key
buttons with identity cards. Use white pages, grey cards, ghost
secondary actions, standard page transitions, and a “Getting ready”
screen during joining. Native profile setup keeps a horizontal modal
transition. Identity cards load signed name/avatar metadata from saved
communities without switching accounts; unavailable profiles use a
neutral fallback. Cards show only the avatar, a 16px medium-weight name,
and muted npub; community lists are omitted. Retiring profile setup
closes its avatar editor too.

### Related issue

Follow-up to #8687 and #8599; no duplicate PR found.

### Testing

- Formatting and analysis passed. Full suite with hosted-auth defines:
4,019 passed, 5 skipped; includes profile-fetch and cancellation
regressions.
- Prior iOS device preview and native profile tests passed; hosted CI
native Swift checks passed on the preceding head. Latest Dart fixes are
not yet reinstalled on the phone.
- Simulator walkthrough and owner-tested iPhone preview use sample
identities and simulated admission.

Sample-data previews; the identity snapshot shows the
unavailable-profile fallback rendered by Flutter. Media is attached here
only.

| Community picker | Add Community | Saved identity |
| --- | --- | --- |
| ![Shortened community
name](https://github.com/user-attachments/assets/cf0af6aa-3a3d-4ffe-b469-e26f9bb92c30)
| ![White Add Community
page](https://github.com/user-attachments/assets/b2f71c77-1aa0-465a-b302-7eb5f1e92819)
| ![Saved identity card and ghost import
action](https://github.com/user-attachments/assets/47f20f34-f047-4ce5-befa-f56834096e95)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-10-30 19:00:42 +00:00
committed by GitHub
parent 0c82435ae0
commit f3a7bab098
32 changed files with 1652 additions and 232 deletions

View File

@@ -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 {

View File

@@ -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 }

View File

@@ -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);

View File

@@ -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<NavigatorState>();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!active || !context.mounted) return;
route = PageRouteBuilder<void>(
route = onboardingRoute<void>(
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<void>(
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<NavigatorState> 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<Object?>(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<void>(
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();
}
},
),
),
],
);
}
}

View File

@@ -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'),
),

View File

@@ -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),
],
),
),
);
}
}

View File

@@ -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<bool> 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 <DeviceIdentityChoice>[];
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 = <Widget>[
for (final identity in saved)
SavedIdentityCard(
identity: identity,
onSelect: busy.value ? null : () => select(pubkey: identity.pubkey),
),
];
final joinActions = <Widget>[
if (choices.connectionState == ConnectionState.done) ...[
button(
onPressed: busy.value
? null
: () async {
if (fullScreen) {
final imported = await Navigator.of(context).push<bool>(
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<bool>(
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++;

View File

@@ -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<void>(
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,
);
}
}

View File

@@ -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'),
),

View File

@@ -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 = <String, Object>{
'dark': theme.brightness == Brightness.dark,
'pageBackgroundArgb': theme.colorScheme.surface.toARGB32(),
@@ -88,25 +91,24 @@ class ProfileSetupPage extends HookConsumerWidget {
Future<void> fallback() async {
if (!current()) return;
await showBuzzModalBottomSheet<void>(
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<void>(
onboardingRoute<void>(
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((_) {

View File

@@ -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() {

View File

@@ -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<UserProfile?> loadProfile(
String pubkey,
Future<UserProfile?> Function(RelayConfig config) fetch,
) async {
final seen = <String>{};
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<String, dynamic>;
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<AccountIdentitySnapshot> adopt(
String pubkey,

View File

@@ -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)

View File

@@ -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<String, String>? 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<Map<String, String>> headers(RelayConfig config) async {
if (!protects(config)) return const {};
final relay = Uri.parse(config.wsUrl).replace(path: '').toString();

View File

@@ -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<http.Client Function()>(
(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<UserProfile?, String>((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<List<int>>? 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<UserProfile?> _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>();
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();
}
}

View File

@@ -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(),
),

View File

@@ -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<MediaImageProvider> {
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<String, DateTime> _cooldownUntil = {};
@@ -105,15 +109,32 @@ class MediaImageProvider extends ImageProvider<MediaImageProvider> {
try {
final abort = Completer<void>();
final deadline = Timer(requestTimeout, abort.complete);
StreamIterator<List<int>>? 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);

View File

@@ -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,

View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
/// A standard onboarding route that skips motion when the OS requests it.
Route<T> onboardingRoute<T>(
BuildContext context, {
required WidgetBuilder builder,
RouteSettings? settings,
}) {
if (MediaQuery.disableAnimationsOf(context)) {
return PageRouteBuilder<T>(
settings: settings,
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
pageBuilder: (context, _, _) => Semantics(
scopesRoute: true,
explicitChildNodes: true,
child: builder(context),
),
);
}
return MaterialPageRoute<T>(settings: settings, builder: builder);
}
/// Declarative counterpart of [onboardingRoute] for nested onboarding steps.
class OnboardingPage<T> extends MaterialPage<T> {
const OnboardingPage({super.key, required super.child});
@override
Route<T> createRoute(BuildContext context) {
if (!MediaQuery.disableAnimationsOf(context)) {
return super.createRoute(context);
}
return _InstantOnboardingRoute<T>(this);
}
}
class _InstantOnboardingRoute<T> extends PageRouteBuilder<T> {
_InstantOnboardingRoute(OnboardingPage<T> page)
: super(
settings: page,
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
pageBuilder: (_, _, _) => const SizedBox.shrink(),
);
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) => Semantics(
scopesRoute: true,
explicitChildNodes: true,
child: (settings as OnboardingPage<T>).child,
);
}

View File

@@ -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<Widget> 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(

View File

@@ -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),

View File

@@ -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,
);

View File

@@ -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<CommunityGridOption>(
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);
}

View File

@@ -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,

View File

@@ -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());

View File

@@ -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<NavigatorObserver> 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<bool>(
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 = <Route<dynamic>>[];
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) =>
routes.add(route);
}

View File

@@ -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<UserProfile?>();
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<AvatarImage>(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<void>();
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<AvatarImage>(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);
});
}

View File

@@ -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<void>();
final started = Completer<void>();
final paths = <String>[];
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,
}
: <String, dynamic>{};
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,
);
}

View File

@@ -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<List<int>>(
onCancel: () {
cancelled = true;
},
);
}
late final StreamController<List<int>> body;
bool closed = false;
bool cancelled = false;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async =>
http.StreamedResponse(body.stream, 200);
@override
void close() {
closed = true;
unawaited(body.close());
}
}

View File

@@ -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<void>();
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);

View File

@@ -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<List<int>>(
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<http.StreamedResponse>().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<String, dynamic>;
if (invalid) event['sig'] = '0' * 128;
final requests = <String>[];
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<http.StreamedResponse> Function(http.BaseRequest) respond;
bool closed = false;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) =>
respond(request);
@override
void close() {
closed = true;
}
}
class _SignedOutAccount extends BuilderlabAccountClient {
int restores = 0;
@override
Future<BuilderlabAccount?> restore() async {
restores++;
return null;
}
}

View File

@@ -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<List<int>>(
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<FormatException>());
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<Object?> _waitError(ImageStream stream) async {
return e;
}
}
class _StreamingClient extends http.BaseClient {
_StreamingClient(this.respond);
final Future<http.StreamedResponse> Function(http.BaseRequest) respond;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) =>
respond(request);
}