(null);
const requestIdRef = useRef(0);
@@ -268,6 +282,7 @@ export function MobilePairingCard({
setStep("generating");
setQrUri(null);
setSasCode(null);
+ setCodeEntry(true);
setError(null);
startPairing().then(
@@ -293,6 +308,7 @@ export function MobilePairingCard({
setStep("idle");
setQrUri(null);
setSasCode(null);
+ setCodeEntry(true);
setError(null);
if (!currentPubkey) {
@@ -302,11 +318,22 @@ export function MobilePairingCard({
let cancelled = false;
const unlisteners: (() => void)[] = [];
- listen<{ sas: string }>("pairing-sas-received", (event) => {
- if (!cancelled && pairingActiveRef.current) {
- setSasCode(event.payload.sas);
- setStep("sas");
- }
+ listen<{ sas: string; code_entry?: boolean }>(
+ "pairing-sas-received",
+ (event) => {
+ if (!cancelled && pairingActiveRef.current) {
+ setSasCode(event.payload.sas);
+ setCodeEntry(event.payload.code_entry === true);
+ setStep("sas");
+ }
+ },
+ ).then((fn) => {
+ if (cancelled) fn();
+ else unlisteners.push(fn);
+ });
+
+ listen("pairing-code-entered", () => {
+ if (!cancelled && pairingActiveRef.current) setStep("transferring");
}).then((fn) => {
if (cancelled) fn();
else unlisteners.push(fn);
@@ -387,7 +414,7 @@ export function MobilePairingCard({
function handleDenySas() {
pairingActiveRef.current = false;
cancelPairing().catch(() => {});
- setError("The codes didn't match. Pairing was canceled.");
+ setError("Pairing was canceled.");
setStep("error");
}
@@ -417,7 +444,7 @@ export function MobilePairingCard({
and is visually hidden, so it changes nothing on screen. */}
{step === "sas" && sasCode
- ? `Verification code ${sasCode.slice(0, 3)} ${sasCode.slice(3, 6)} ready. Check that it matches on your mobile device, then confirm the codes match.`
+ ? `Verification code ${sasCode.slice(0, 3)} ${sasCode.slice(3, 6)} ready. ${codeEntry ? "Enter this code in the Buzz app on your phone." : "Check that it matches on your mobile device, then confirm the codes match."}`
: step === "transferring"
? "Codes confirmed. Pairing your mobile device."
: step === "done"
@@ -446,6 +473,7 @@ export function MobilePairingCard({
>
{step === "sas" && sasCode ? (
void handleConfirmSas()}
onDeny={handleDenySas}
sasCode={sasCode}
@@ -553,7 +581,7 @@ export function MobilePairingCard({
-
+
diff --git a/desktop/tests/e2e/mobile-pairing-qr.spec.ts b/desktop/tests/e2e/mobile-pairing-qr.spec.ts
index 731cce17e56..a137f0279ea 100644
--- a/desktop/tests/e2e/mobile-pairing-qr.spec.ts
+++ b/desktop/tests/e2e/mobile-pairing-qr.spec.ts
@@ -69,7 +69,7 @@ test("mobile pairing starts on demand and reveals the QR code", async ({
await expect(startButton).toHaveText("Start pairing");
await expect(steps.getByText("Scan QR code", { exact: true })).toBeVisible();
await expect(
- steps.getByText("Confirm mobile code", { exact: true }),
+ steps.getByText("Enter code on your phone", { exact: true }),
).toBeVisible();
await expect(
finalStep.getByText("Pair your mobile app", { exact: true }),
@@ -412,12 +412,11 @@ test("late pairing events are ignored after canceling", async ({ page }) => {
await confirmation.getByTestId("deny-sas").click();
await expect(confirmation).toHaveCount(0);
- await expect(
- card.getByText("The codes didn't match. Pairing was canceled."),
- ).toBeVisible();
+ await expect(card.getByText("Pairing was canceled.")).toBeVisible();
await emitPairingEvent(page, "pairing-complete");
await emitPairingEvent(page, "pairing-sas-received", { sas: "654321" });
+ await emitPairingEvent(page, "pairing-code-entered");
await expect(confirmation).toHaveCount(0);
await expect(
@@ -425,9 +424,7 @@ test("late pairing events are ignored after canceling", async ({ page }) => {
.getByTestId("mobile-pairing-final-step")
.getByText("Paired", { exact: true }),
).toHaveCount(0);
- await expect(
- card.getByText("The codes didn't match. Pairing was canceled."),
- ).toBeVisible();
+ await expect(card.getByText("Pairing was canceled.")).toBeVisible();
});
test("step completion respects reduced motion", async ({ page }) => {
@@ -452,3 +449,44 @@ test("step completion respects reduced motion", async ({ page }) => {
await expect(completedContent).toHaveCSS("opacity", "1");
await expect(completedContent).toHaveCSS("transform", "none");
});
+
+test("code-entry phones advance without desktop confirmation", async ({
+ page,
+}) => {
+ await page.goto("/");
+ await page.getByTestId("open-settings").click();
+ await page.getByTestId("profile-popover-settings").click();
+ await page.getByTestId("settings-nav-mobile").click();
+ const card = page.getByTestId("mobile-pairing-card");
+ await card.getByTestId("start-pairing-button").click();
+ await expect(page.getByTestId("mobile-pairing-qr")).toBeVisible();
+ await emitPairingEvent(page, "pairing-sas-received", {
+ sas: "012345",
+ code_entry: true,
+ });
+ await expect(card.getByTestId("pairing-sas-title")).toHaveText(
+ "Enter code on your phone",
+ );
+ await expect(card.getByTestId("confirm-sas")).toHaveCount(0);
+ await expect(card.getByTestId("deny-sas")).toBeVisible();
+ await expect(card.getByTestId("pairing-status")).toContainText(
+ "Enter this code in the Buzz app on your phone.",
+ );
+ await waitForAnimations(page);
+ mkdirSync(SCREENSHOT_DIR, { recursive: true });
+ await card.screenshot({ path: `${SCREENSHOT_DIR}/pairing-code-entry.png` });
+ await emitPairingEvent(page, "pairing-code-entered");
+ await expect(card.getByTestId("pairing-transfer-spinner")).toBeVisible();
+ expect(
+ await page.evaluate(
+ () =>
+ (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
+ (entry) => entry.command === "confirm_pairing_sas",
+ ).length,
+ ),
+ ).toBe(0);
+ await emitPairingEvent(page, "pairing-complete");
+ await expect(
+ card.getByTestId("mobile-pairing-final-step-indicator"),
+ ).toHaveAttribute("data-completed", "true");
+});
diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/HapticsPlugin.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/HapticsPlugin.kt
new file mode 100644
index 00000000000..2ccca89cdb8
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/HapticsPlugin.kt
@@ -0,0 +1,30 @@
+package xyz.block.buzz.mobile
+
+import android.app.Activity
+import android.os.Build
+import android.view.HapticFeedbackConstants
+import io.flutter.plugin.common.BinaryMessenger
+import io.flutter.plugin.common.MethodChannel
+
+/** Platform error feedback respects the user's system haptic preference. */
+internal class HapticsPlugin(activity: Activity, messenger: BinaryMessenger) {
+ private val channel = MethodChannel(messenger, "buzz/haptics")
+
+ init {
+ channel.setMethodCallHandler { call, result ->
+ if (call.method == "error") {
+ val feedback = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ HapticFeedbackConstants.REJECT
+ } else {
+ HapticFeedbackConstants.LONG_PRESS
+ }
+ activity.window.decorView.performHapticFeedback(feedback)
+ result.success(null)
+ } else {
+ result.notImplemented()
+ }
+ }
+ }
+
+ fun dispose() { channel.setMethodCallHandler(null) }
+}
diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt
index 7f3017b9cd5..8425affdb31 100644
--- a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt
+++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt
@@ -130,9 +130,11 @@ class MainActivity : FlutterFragmentActivity() {
private var ageSignalChannel: MethodChannel? = null
private val ageSignalRequest = AgeSignalRequest()
private var huddleMediaPlugin: HuddleMediaPlugin? = null
+ private var hapticsPlugin: HapticsPlugin? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
+ hapticsPlugin = HapticsPlugin(this, flutterEngine.dartExecutor.binaryMessenger)
huddleMediaPlugin = HuddleMediaPlugin(
this,
@@ -202,6 +204,8 @@ class MainActivity : FlutterFragmentActivity() {
}
override fun onDestroy() {
+ hapticsPlugin?.dispose()
+ hapticsPlugin = null
ageSignalRequest.retire()
huddleMediaPlugin?.dispose()
huddleMediaPlugin = null
diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift
index 1ab8cea7b05..d07baeff40a 100644
--- a/mobile/ios/Runner/AppDelegate.swift
+++ b/mobile/ios/Runner/AppDelegate.swift
@@ -85,13 +85,13 @@ import os.log
binaryMessenger: messenger
)
hapticsChannel?.setMethodCallHandler { call, result in
- guard call.method == "success" else {
+ guard call.method == "success" || call.method == "error" else {
result(FlutterMethodNotImplemented)
return
}
let generator = UINotificationFeedbackGenerator()
generator.prepare()
- generator.notificationOccurred(.success)
+ generator.notificationOccurred(call.method == "error" ? .error : .success)
result(nil)
}
qrScannerChannel = FlutterMethodChannel(
diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart
index 25551cb9401..03c73bb9155 100644
--- a/mobile/lib/app.dart
+++ b/mobile/lib/app.dart
@@ -1,5 +1,7 @@
import 'dart:async';
import 'shared/widgets/ios_navigation_metrics.dart';
+import 'shared/community/paired_community_landing.dart';
+import 'shared/community/community_loading_surface.dart';
import 'package:app_badge_plus/app_badge_plus.dart';
import 'package:flutter/material.dart';
@@ -315,6 +317,7 @@ class App extends HookConsumerWidget {
);
final schemeName = communityTheme.theme;
final authState = ref.watch(authProvider);
+ final pairedCommunity = ref.watch(pairedCommunityLandingProvider);
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -408,7 +411,27 @@ class App extends HookConsumerWidget {
child: AppMarkdownTheme(
child: MobileHuddleShell(
navigatorKey: _mobileRootNavigatorKey,
- child: EmojiBurstOverlay(child: child ?? const SizedBox.shrink()),
+ child: EmojiBurstOverlay(
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ ExcludeSemantics(
+ excluding: pairedCommunity != null,
+ child: AbsorbPointer(
+ absorbing: pairedCommunity != null,
+ child: child ?? const SizedBox.shrink(),
+ ),
+ ),
+ // Cover the navigator while onboarding is removed and the
+ // destination installs the matching avatar-flight route.
+ if (pairedCommunity != null)
+ CommunityLoadingSurface(
+ name: pairedCommunity.name,
+ relayUrl: pairedCommunity.relayUrl,
+ ),
+ ],
+ ),
+ ),
),
),
),
diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart
index 9ee9af12947..b437ac33e4d 100644
--- a/mobile/lib/features/channels/channels_page.dart
+++ b/mobile/lib/features/channels/channels_page.dart
@@ -13,6 +13,8 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/auth/auth.dart';
import '../../shared/community/community_icon_provider.dart';
+import '../../shared/community/community_avatar.dart';
+import '../../shared/community/paired_community_landing.dart';
import '../../shared/community/community_membership_provider.dart';
import '../../shared/widgets/app_list_card_item.dart';
import '../../shared/widgets/app_list.dart';
@@ -96,7 +98,6 @@ const double _kChannelLabelInset =
/// sections while the labels stay on [_kChannelLabelInset].
const double _kDmAvatarSize = _kChannelIconSize;
-const double _kTopSectionCommunityAvatarSize = 40.0;
const double _kTopSectionProfileAvatarSize = 36.0;
const double _kTopSectionBottomPadding = Grid.xxs;
@@ -369,6 +370,54 @@ class ChannelsPage extends HookConsumerWidget {
return null;
}
+ final arrivingCommunity = ref.watch(pairedCommunityLandingProvider);
+ useEffect(() {
+ if (arrivingCommunity == null) return null;
+ var cancelled = false;
+ Future revealPairedCommunity() async {
+ // Add-community pairing is a pushed route. Let it finish dismissing
+ // before placing the loading surface above the destination Home page.
+ do {
+ await WidgetsBinding.instance.endOfFrame;
+ if (cancelled || !context.mounted) return;
+ } while (ModalRoute.of(context)?.isCurrent == false);
+ if (ref.read(pairedCommunityLandingProvider)?.id !=
+ arrivingCommunity.id) {
+ return;
+ }
+ ref.read(pairedCommunityLandingProvider.notifier).clear();
+ communityFlightActive.value = true;
+ final completedPairing = ref.read(pairingProvider);
+ final navigator = Navigator.of(context, rootNavigator: true);
+ await navigator.push(
+ PageRouteBuilder(
+ opaque: false,
+ transitionDuration: Duration.zero,
+ reverseTransitionDuration: Duration.zero,
+ pageBuilder: (_, _, _) => _CommunitySwitcherPage(
+ arrivingCommunity: arrivingCommunity,
+ destination: measureCommunityAvatar(),
+ prepareLanding: prepareCommunityLanding,
+ onFlightChanged: (flying) {
+ if (context.mounted) communityFlightActive.value = flying;
+ },
+ onTransitionProgress: onSettingsTransitionProgress,
+ ),
+ ),
+ );
+ if (context.mounted) {
+ communityFlightActive.value = false;
+ if (completedPairing.status == PairingStatus.success &&
+ identical(ref.read(pairingProvider), completedPairing)) {
+ ref.read(pairingProvider.notifier).reset();
+ }
+ }
+ }
+
+ unawaited(revealPairedCommunity());
+ return () => cancelled = true;
+ }, [arrivingCommunity]);
+
void openCommunityGrid() {
if (!context.mounted) return;
ref.invalidate(communityIconProvider);
diff --git a/mobile/lib/features/channels/channels_page/community.dart b/mobile/lib/features/channels/channels_page/community.dart
index 3e966a62d4c..b2ebe1f6ab0 100644
--- a/mobile/lib/features/channels/channels_page/community.dart
+++ b/mobile/lib/features/channels/channels_page/community.dart
@@ -131,7 +131,7 @@ class _CommunityIndicator extends ConsumerWidget {
label: 'Community settings',
child: Opacity(
opacity: hidden ? 0 : 1,
- child: _CommunityAvatar(
+ child: CommunityAvatar(
key: avatarKey,
name: activeCommunity?.name,
relayUrl: activeCommunity?.relayUrl,
@@ -172,50 +172,3 @@ class _CommunityHeaderTitle extends ConsumerWidget {
);
}
}
-
-class _CommunityAvatar extends ConsumerWidget {
- final String? name;
- final String? relayUrl;
- final double size;
-
- const _CommunityAvatar({
- super.key,
- required this.name,
- this.relayUrl,
- this.size = _kTopSectionCommunityAvatarSize,
- });
-
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final trimmedName = name?.trim();
- final initial = trimmedName != null && trimmedName.isNotEmpty
- ? trimmedName.characters.first.toUpperCase()
- : '?';
- final relay = relayUrl;
- final iconUrl = relay == null
- ? null
- : ref.watch(communityIconPresentationProvider(relay));
-
- return ClipRSuperellipse(
- borderRadius: BorderRadius.circular(Radii.card * size / 40),
- child: ColoredBox(
- color: context.colors.primaryContainer,
- child: SizedBox.square(
- dimension: size,
- child: AvatarImageContent(
- imageUrl: iconUrl,
- fallback: Text(
- initial,
- textScaler: TextScaler.noScaling,
- style: context.textTheme.labelMedium?.copyWith(
- fontSize: size * 0.38,
- color: context.colors.onPrimaryContainer,
- fontWeight: FontWeight.w600,
- ),
- ),
- ),
- ),
- ),
- );
- }
-}
diff --git a/mobile/lib/features/channels/channels_page/community_switcher.dart b/mobile/lib/features/channels/channels_page/community_switcher.dart
index 44fd2d38944..97214db8a8b 100644
--- a/mobile/lib/features/channels/channels_page/community_switcher.dart
+++ b/mobile/lib/features/channels/channels_page/community_switcher.dart
@@ -65,12 +65,14 @@ class _CommunityFlight {
class _CommunitySwitcherPage extends HookConsumerWidget {
const _CommunitySwitcherPage({
required this.destination,
+ this.arrivingCommunity,
required this.prepareLanding,
required this.onFlightChanged,
required this.onTransitionProgress,
});
final Rect? destination;
+ final Community? arrivingCommunity;
final Future Function() prepareLanding;
final ValueChanged onFlightChanged;
final ValueChanged onTransitionProgress;
@@ -80,13 +82,26 @@ class _CommunitySwitcherPage extends HookConsumerWidget {
final communitiesAsync = ref.watch(communityListProvider);
final activeId = ref.watch(activeCommunityProvider).value?.id;
final isEditing = useState(false);
- final flight = useState<_CommunityFlight?>(null);
+ final flight = useState<_CommunityFlight?>(
+ arrivingCommunity == null
+ ? null
+ : _CommunityFlight(
+ arrivingCommunity!,
+ Rect.fromCenter(
+ center: MediaQuery.sizeOf(context).center(Offset.zero),
+ width: _communityCenteredAvatarSize,
+ height: _communityCenteredAvatarSize,
+ ),
+ null,
+ ),
+ );
final landingBounds = useState(destination);
if (flight.value != null) ref.watch(_communityContentReadyProvider);
final error = useState(null);
final failedCommunityId = useState(null);
final centering = useAnimationController(
duration: _communityCenterDuration,
+ initialValue: arrivingCommunity == null ? 0 : 1,
);
final controller = useAnimationController(
duration: _communityDepartureDuration,
@@ -100,7 +115,7 @@ class _CommunitySwitcherPage extends HookConsumerWidget {
// Keep avatar decoding/painting out of the per-frame motion build.
child: Material(
type: MaterialType.transparency,
- child: _CommunityAvatar(
+ child: CommunityAvatar(
name: selected.community.name,
relayUrl: selected.community.relayUrl,
size: _communityGridAvatarSize,
@@ -133,21 +148,27 @@ class _CommunitySwitcherPage extends HookConsumerWidget {
}
}
- Future selectCommunity(Community community, Rect origin) async {
- if (flight.value != null) return;
- if (community.id == activeId && community.id != failedCommunityId.value) {
+ Future selectCommunity(
+ Community community,
+ Rect origin, {
+ bool arriving = false,
+ }) async {
+ if (flight.value != null && !arriving) return;
+ if (!arriving &&
+ community.id == activeId &&
+ community.id != failedCommunityId.value) {
Navigator.of(context).pop();
return;
}
error.value = null;
flight.value = _CommunityFlight(community, origin, activeId);
onFlightChanged(true);
- unawaited(HapticFeedback.selectionClick());
- final animate = !reducedMotion && destination != null;
+ if (!arriving) unawaited(HapticFeedback.selectionClick());
+ final animate = !reducedMotion && (arriving || destination != null);
try {
// Relay teardown, credential changes and provider hydration must not
// compete with the grid-to-center motion on the UI isolate.
- if (animate) {
+ if (animate && !arriving) {
await centering.forward(from: 0).orCancel;
} else {
centering.value = 1;
@@ -155,15 +176,17 @@ class _CommunitySwitcherPage extends HookConsumerWidget {
if (!context.mounted) return;
await WidgetsBinding.instance.endOfFrame;
if (!context.mounted) return;
- await ref
- .read(communityListProvider.notifier)
- .switchCommunity(community.id);
+ if (!arriving) {
+ await ref
+ .read(communityListProvider.notifier)
+ .switchCommunity(community.id);
+ }
if (!context.mounted) return;
await ref.read(activeCommunityProvider.future);
if (!context.mounted) return;
// Refresh this scope before awaiting its content. ChannelsNotifier
// fences cached snapshots to the destination relay and identity.
- ref.invalidate(channelsProvider);
+ if (!arriving) ref.invalidate(channelsProvider);
await (() async {
await waitForContent();
if (!context.mounted) return;
@@ -197,6 +220,21 @@ class _CommunitySwitcherPage extends HookConsumerWidget {
}
}
+ useEffect(() {
+ if (arrivingCommunity == null) return null;
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!context.mounted) return;
+ unawaited(
+ selectCommunity(
+ arrivingCommunity!,
+ flight.value!.origin,
+ arriving: true,
+ ),
+ );
+ });
+ return null;
+ }, const []);
+
Future addCommunity() async {
final navigator = Navigator.of(context, rootNavigator: true);
final route = ModalRoute.of(context);
@@ -215,131 +253,140 @@ class _CommunitySwitcherPage extends HookConsumerWidget {
);
}
- final surface = Material(
- key: const Key('community-switcher-page'),
- color: context.colors.surface,
- child: SafeArea(
- top: defaultTargetPlatform != TargetPlatform.iOS,
- child: Column(
- children: [
- if (defaultTargetPlatform == TargetPlatform.iOS)
- SizedBox(
- height: frostedAppBarHeight(context),
- child: IosNavigationBar(
- title: '',
- leading: IosNavigationAction(
- label: 'Close community switcher',
- symbol: 'xmark',
- onPressed: () => Navigator.of(context).pop(),
- ),
- actions: [
- IosNavigationAction(
- label: isEditing.value ? 'Done' : 'Edit',
- onPressed: () => isEditing.value = !isEditing.value,
- ),
- ],
+ final ios = defaultTargetPlatform == TargetPlatform.iOS;
+ final header = ios
+ ? SizedBox(
+ height: frostedAppBarHeight(context),
+ child: IosNavigationBar(
+ title: '',
+ leading: IosNavigationAction(
+ label: 'Close community switcher',
+ symbol: 'xmark',
+ onPressed: () => Navigator.of(context).pop(),
+ ),
+ actions: [
+ IosNavigationAction(
+ label: isEditing.value ? 'Done' : 'Edit',
+ onPressed: () => isEditing.value = !isEditing.value,
),
- )
- else
- Padding(
- padding: const EdgeInsets.all(Grid.xxs),
- child: Row(
- children: [
- IconButton(
- tooltip: 'Close community switcher',
- onPressed: () => Navigator.of(context).pop(),
- icon: const Icon(LucideIcons.x),
- ),
- const Spacer(),
- TextButton(
- key: const Key('community-switcher-edit'),
- onPressed: () => isEditing.value = !isEditing.value,
- child: Text(isEditing.value ? 'Done' : 'Edit'),
- ),
- ],
+ ],
+ ),
+ )
+ : Padding(
+ padding: const EdgeInsets.all(Grid.xxs),
+ child: Row(
+ children: [
+ IconButton(
+ tooltip: 'Close community switcher',
+ onPressed: () => Navigator.of(context).pop(),
+ icon: const Icon(LucideIcons.x),
),
- ),
- Expanded(
- child: SingleChildScrollView(
- padding: const EdgeInsets.fromLTRB(
- Grid.gutter,
- Grid.xs,
- Grid.gutter,
- Grid.md,
+ const Spacer(),
+ TextButton(
+ key: const Key('community-switcher-edit'),
+ onPressed: () => isEditing.value = !isEditing.value,
+ child: Text(isEditing.value ? 'Done' : 'Edit'),
),
- child: Center(
- child: ConstrainedBox(
- constraints: const BoxConstraints(maxWidth: 440),
- child: Column(
- children: [
- Text(
- 'Switch Community',
- key: const Key('community-switcher-title'),
- textAlign: TextAlign.center,
- style: context.textTheme.titleLarge?.copyWith(
- fontWeight: FontWeight.w600,
- ),
- ),
- const SizedBox(height: Grid.md),
- if (error.value != null)
- Padding(
- padding: const EdgeInsets.only(bottom: Grid.xs),
- child: Semantics(
- liveRegion: true,
- child: Text(
- error.value!,
- style: TextStyle(color: context.colors.error),
- ),
- ),
- ),
- communitiesAsync.when(
- loading: () => const Center(
- child: BuzzLoadingIndicator(
- size: 40,
- semanticLabel: 'Loading communities',
- ),
- ),
- error: (e, _) =>
- Text('Error loading communities: $e'),
- data: (communities) => _CommunityGrid(
- children: [
- for (final community in communities)
- _CommunityGridTile(
- community: community,
- // Keep the current-community marker stable
- // while the selected avatar leaves the grid.
- isActive:
- community.id ==
- (flight.value == null
- ? activeId
- : flight.value!.previousCommunityId),
- isEditing: isEditing.value,
- hidden:
- flight.value?.community.id ==
- community.id,
- onSelect: selectCommunity,
- onRemove: () => _confirmRemoveCommunity(
- context,
- ref,
- community,
- closeSheetAfterRemoval:
- community.id == activeId,
- ),
- ),
- _CommunityGridAdd(onTap: addCommunity),
- ],
- ),
- ),
- ],
+ ],
+ ),
+ );
+ final body = SingleChildScrollView(
+ padding: const EdgeInsets.fromLTRB(
+ Grid.gutter,
+ Grid.xs,
+ Grid.gutter,
+ Grid.md,
+ ),
+ child: Center(
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 440),
+ child: Column(
+ children: [
+ Text(
+ 'Switch Community',
+ key: const Key('community-switcher-title'),
+ textAlign: TextAlign.center,
+ style: context.textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: Grid.md),
+ if (error.value != null)
+ Padding(
+ padding: const EdgeInsets.only(bottom: Grid.xs),
+ child: Semantics(
+ liveRegion: true,
+ child: Text(
+ error.value!,
+ style: TextStyle(color: context.colors.error),
),
),
),
+ communitiesAsync.when(
+ loading: () => const Center(
+ child: BuzzLoadingIndicator(
+ size: 40,
+ semanticLabel: 'Loading communities',
+ ),
+ ),
+ error: (e, _) => Text('Error loading communities: $e'),
+ data: (communities) => _CommunityGrid(
+ children: [
+ for (final community in communities)
+ _CommunityGridTile(
+ community: community,
+ // Keep the current-community marker stable
+ // while the selected avatar leaves the grid.
+ isActive:
+ community.id ==
+ (flight.value == null
+ ? activeId
+ : flight.value!.previousCommunityId),
+ isEditing: isEditing.value,
+ hidden: flight.value?.community.id == community.id,
+ onSelect: selectCommunity,
+ onRemove: () => _confirmRemoveCommunity(
+ context,
+ ref,
+ community,
+ closeSheetAfterRemoval: community.id == activeId,
+ ),
+ ),
+ _CommunityGridAdd(onTap: addCommunity),
+ ],
+ ),
),
- ),
- ],
+ ],
+ ),
),
),
);
+ final surface = Material(
+ key: const Key('community-switcher-page'),
+ color: context.colors.surface,
+ child: SafeArea(
+ top: !ios,
+ child: ios
+ ? Stack(
+ children: [
+ Positioned.fill(
+ top: frostedAppBarHeight(context),
+ child: body,
+ ),
+ // Paint the native bar last, as in FrostedScaffold. Painting
+ // Flutter content after a UIKit view can hide that content
+ // during this route's opacity/scale composition on iOS.
+ Positioned(top: 0, left: 0, right: 0, child: header),
+ ],
+ )
+ : Column(
+ children: [
+ header,
+ Expanded(child: body),
+ ],
+ ),
+ ),
+ );
return PopScope(
canPop: flight.value == null,
@@ -570,7 +617,7 @@ class _CommunityGridTile extends HookWidget {
opacity: hidden ? 0 : 1,
child: SizedBox(
key: avatarKey,
- child: _CommunityAvatar(
+ child: CommunityAvatar(
key: Key('community-switcher-avatar-${community.id}'),
name: community.name,
relayUrl: community.relayUrl,
diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart
index b88d86a89e3..ff5863eec20 100644
--- a/mobile/lib/features/pairing/pairing_page.dart
+++ b/mobile/lib/features/pairing/pairing_page.dart
@@ -11,6 +11,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/security/sensitive_action_authorizer.dart';
+import '../../shared/error_haptic.dart';
+import '../../shared/community/community.dart';
+import '../../shared/community/community_loading_surface.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/ios_glass_navigation_button.dart';
@@ -22,6 +25,9 @@ part 'pairing_page/onboarding_background.dart';
part 'pairing_page/onboarding_colors.dart';
part 'pairing_page/onboarding_glass_button.dart';
part 'pairing_page/pairing_welcome_view.dart';
+part 'pairing_page/sas_verification_view.dart';
+part 'pairing_page/pairing_code_entry.dart';
+part 'pairing_page/pairing_error_shake.dart';
class PairingPage extends HookConsumerWidget {
/// When true, the pairing page is being used to add a new community
@@ -51,8 +57,14 @@ class PairingPage extends HookConsumerWidget {
if (addingCommunity && pairingState.status == PairingStatus.success) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
- ref.read(pairingProvider.notifier).reset();
- Navigator.of(context).pop();
+ final route = ModalRoute.of(context);
+ if (route != null && route.isActive) {
+ final notifier = ref.read(pairingProvider.notifier);
+ Navigator.of(context).removeRoute(route);
+ // Reset only after removal so the dismissed page cannot flash its
+ // scanner, and subsequent recovery/onboarding starts fresh.
+ notifier.reset();
+ }
}
});
}
@@ -151,7 +163,20 @@ class PairingPage extends HookConsumerWidget {
)
: null;
- final pairingScaffold = isVerifyingSas
+ final showLoading =
+ pairingState.status == PairingStatus.transferring ||
+ pairingState.status == PairingStatus.storing ||
+ pairingState.status == PairingStatus.success;
+ final destinationRelay = pairingState.destinationRelayUrl;
+ final pairingScaffold = showLoading
+ ? CommunityLoadingSurface(
+ key: const Key('pairing-community-loading'),
+ name: destinationRelay == null
+ ? null
+ : Community.nameFromUrl(destinationRelay),
+ relayUrl: destinationRelay,
+ )
+ : isVerifyingSas
? AnnotatedRegion(
key: const Key('pairing-sas-system-overlay'),
value: onboardingSystemOverlayStyle,
@@ -162,12 +187,14 @@ class PairingPage extends HookConsumerWidget {
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.sm),
child: _SasVerificationView(
+ key: ValueKey(pairingState.sasCode),
sasCode: pairingState.sasCode ?? '------',
+ verifyDesktopCode: pairingState.requiresDesktopCode
+ ? ref.read(pairingProvider.notifier).verifyDesktopCode
+ : null,
confirmed: pairingState.userConfirmedSas,
sendsIdentityToDesktop:
pairingState.sendsIdentityToDesktop,
- protectSensitiveActions:
- pairingState.protectSensitiveActions,
biometricLabel: biometricProtectionLabel(
defaultTargetPlatform,
enrolledBiometrics.value ?? const [],
@@ -220,7 +247,7 @@ class PairingPage extends HookConsumerWidget {
final appSurface = PopScope(
key: const Key('pairing-pop-scope'),
onPopInvokedWithResult: (didPop, _) {
- if (didPop) {
+ if (didPop && pairingState.status != PairingStatus.success) {
ref.read(pairingProvider.notifier).reset();
}
},
@@ -240,204 +267,3 @@ class PairingPage extends HookConsumerWidget {
);
}
}
-
-/// SAS verification screen shown during NIP-AB pairing.
-class _SasVerificationView extends StatelessWidget {
- static const _digitSize = 54.0;
- static const _digitGap = 6.0;
- static const _digitGroupGap = 14.0;
-
- final String sasCode;
- final bool confirmed;
- final bool sendsIdentityToDesktop;
- final bool protectSensitiveActions;
- final String biometricLabel;
- final String? errorMessage;
- final ValueChanged onProtectionChanged;
- final VoidCallback onConfirm;
- final VoidCallback onDeny;
-
- const _SasVerificationView({
- required this.sasCode,
- required this.confirmed,
- required this.sendsIdentityToDesktop,
- required this.protectSensitiveActions,
- required this.biometricLabel,
- required this.errorMessage,
- required this.onProtectionChanged,
- required this.onConfirm,
- required this.onDeny,
- });
-
- @override
- Widget build(BuildContext context) {
- final verificationContent = Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(
- 'Confirm desktop code',
- textAlign: TextAlign.center,
- style: context.textTheme.headlineSmall?.copyWith(
- color: context._onboardingInk,
- fontWeight: FontWeight.w600,
- letterSpacing: -0.4,
- ),
- ),
- const SizedBox(height: Grid.xxs),
- Text(
- sendsIdentityToDesktop
- ? 'Make sure the six-digit code matches on both devices. Your full Buzz identity will transfer to the desktop and grant it permanent access. Only continue if you started this recovery.'
- : 'Make sure the six-digit code matches on both devices. Your Buzz identity will transfer to this device. Only continue if you started this pairing from your desktop.',
- textAlign: TextAlign.center,
- style: context.textTheme.bodyMedium?.copyWith(
- color: context._onboardingMutedInk,
- ),
- ),
- const SizedBox(height: Grid.md),
- Semantics(
- label:
- 'Confirmation code ${sasCode.substring(0, 3)} ${sasCode.substring(3)}',
- child: ExcludeSemantics(
- child: FittedBox(
- fit: BoxFit.scaleDown,
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- for (var index = 0; index < sasCode.length; index++) ...[
- if (index > 0)
- SizedBox(width: index == 3 ? _digitGroupGap : _digitGap),
- Container(
- key: Key('pairing-sas-code-digit-${index + 1}'),
- width: _digitSize,
- padding: const EdgeInsets.symmetric(vertical: Grid.xs),
- alignment: Alignment.center,
- decoration: BoxDecoration(
- color: context._onboardingInputSurface,
- borderRadius: BorderRadius.circular(12),
- border: Border.all(
- color: context.colors.primary.withValues(alpha: 0.15),
- ),
- ),
- child: Text(
- sasCode[index],
- style: context.textTheme.displaySmall?.copyWith(
- fontWeight: FontWeight.w600,
- color: context._onboardingInk,
- ),
- ),
- ),
- ],
- ],
- ),
- ),
- ),
- ),
- const SizedBox(height: Grid.sm),
- if (!sendsIdentityToDesktop)
- CheckboxListTile(
- key: const Key('protect-sensitive-actions-checkbox'),
- value: protectSensitiveActions,
- onChanged: confirmed
- ? null
- : (value) => onProtectionChanged(value ?? false),
- activeColor: context._onboardingInk,
- checkColor: context._onboardingCtaLabel,
- side: BorderSide(color: context._onboardingInk),
- controlAffinity: ListTileControlAffinity.leading,
- contentPadding: EdgeInsets.zero,
- title: Text(
- biometricLabel,
- style: context.textTheme.bodyMedium?.copyWith(
- color: context._onboardingInk,
- fontWeight: FontWeight.w600,
- ),
- ),
- subtitle: Text(
- 'For secure actions',
- style: context.textTheme.bodySmall?.copyWith(
- color: context._onboardingMutedInk,
- ),
- ),
- ),
- if (errorMessage != null) ...[
- const SizedBox(height: Grid.xs),
- Text(
- errorMessage!,
- textAlign: TextAlign.center,
- style: context.textTheme.bodySmall?.copyWith(
- color: context._onboardingErrorInk,
- ),
- ),
- ],
- ],
- );
-
- final verificationActions = confirmed
- ? Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- BuzzLoadingIndicator(
- size: 24,
- color: context._onboardingInk,
- semanticLabel: 'Connecting',
- ),
- const SizedBox(width: Grid.twelve),
- Text(
- 'Confirmed — waiting for desktop',
- style: context.textTheme.bodySmall?.copyWith(
- color: context._onboardingMutedInk,
- ),
- ),
- ],
- )
- : SizedBox(
- width: double.infinity,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- FilledButton.icon(
- style: context._onboardingButtonStyle,
- onPressed: onConfirm,
- icon: const Icon(LucideIcons.check),
- label: const Text('Codes match'),
- ),
- const SizedBox(height: Grid.xxs),
- TextButton(
- style: context._onboardingSecondaryButtonStyle.copyWith(
- minimumSize: const WidgetStatePropertyAll(
- Size.fromHeight(48),
- ),
- ),
- onPressed: onDeny,
- child: const Text('Cancel'),
- ),
- ],
- ),
- );
-
- return Column(
- children: [
- Expanded(
- child: LayoutBuilder(
- builder: (context, constraints) {
- final verticalPadding = Grid.sm * 2;
- final minimumContentHeight =
- constraints.maxHeight > verticalPadding
- ? constraints.maxHeight - verticalPadding
- : 0.0;
- return SingleChildScrollView(
- padding: const EdgeInsets.symmetric(vertical: Grid.sm),
- child: ConstrainedBox(
- constraints: BoxConstraints(minHeight: minimumContentHeight),
- child: Center(child: verificationContent),
- ),
- );
- },
- ),
- ),
- verificationActions,
- const SizedBox(height: Grid.sm),
- ],
- );
- }
-}
diff --git a/mobile/lib/features/pairing/pairing_page/onboarding_colors.dart b/mobile/lib/features/pairing/pairing_page/onboarding_colors.dart
index faf6a0a48c9..27bda3b5a79 100644
--- a/mobile/lib/features/pairing/pairing_page/onboarding_colors.dart
+++ b/mobile/lib/features/pairing/pairing_page/onboarding_colors.dart
@@ -9,8 +9,7 @@ extension _OnboardingColors on BuildContext {
_onboardingIsDark ? const Color(0xFFAAB8C0) : const Color(0xB3111111);
Color get _onboardingCtaLabel =>
_onboardingIsDark ? const Color(0xFF172229) : const Color(0xFFD7E6F0);
- Color get _onboardingErrorInk =>
- _onboardingIsDark ? const Color(0xFFFFAAA0) : const Color(0xFF7A1025);
+ Color get _onboardingErrorInk => colors.error;
Color get _onboardingInputSurface => _onboardingIsDark
? const Color(0xFF233039)
: Colors.white.withValues(alpha: 0.7);
diff --git a/mobile/lib/features/pairing/pairing_page/pairing_code_entry.dart b/mobile/lib/features/pairing/pairing_page/pairing_code_entry.dart
new file mode 100644
index 00000000000..9199dce9494
--- /dev/null
+++ b/mobile/lib/features/pairing/pairing_page/pairing_code_entry.dart
@@ -0,0 +1,166 @@
+part of '../pairing_page.dart';
+
+/// One native editing buffer keeps paste, deletion and accessibility reliable;
+/// the six visual cells mirror the desktop verification input.
+class _PairingCodeEntry extends HookConsumerWidget {
+ const _PairingCodeEntry({
+ required this.controller,
+ required this.enabled,
+ required this.invalid,
+ required this.onChanged,
+ required this.onSubmitted,
+ required this.onRejectedInput,
+ });
+
+ final TextEditingController controller;
+ final bool enabled;
+ final bool invalid;
+ final ValueChanged onChanged;
+ final VoidCallback onSubmitted;
+ final VoidCallback onRejectedInput;
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final focus = useFocusNode();
+ useListenable(focus);
+ final editing = useValueListenable(controller);
+ final reducedMotion = MediaQuery.disableAnimationsOf(context);
+ final activeIndex = editing.selection.isValid
+ ? editing.selection.start.clamp(0, 5)
+ : editing.text.length.clamp(0, 5);
+
+ void focusCell(int index) {
+ if (!enabled) return;
+ focus.requestFocus();
+ final offset = index.clamp(0, editing.text.length);
+ controller.selection = offset < editing.text.length
+ ? TextSelection(baseOffset: offset, extentOffset: offset + 1)
+ : TextSelection.collapsed(offset: offset);
+ }
+
+ return Stack(
+ alignment: Alignment.center,
+ children: [
+ // The real field remains available to assistive technology and the
+ // keyboard. Visual cells handle pointer placement and display only.
+ Positioned.fill(
+ child: Opacity(
+ opacity: 0,
+ alwaysIncludeSemantics: true,
+ child: TextField(
+ key: const Key('pairing-code-input'),
+ controller: controller,
+ focusNode: focus,
+ enabled: enabled,
+ autofocus: true,
+ keyboardType: TextInputType.number,
+ textInputAction: TextInputAction.done,
+ autofillHints: const [AutofillHints.oneTimeCode],
+ autocorrect: false,
+ enableSuggestions: false,
+ inputFormatters: [
+ FilteringTextInputFormatter.digitsOnly,
+ TextInputFormatter.withFunction((oldValue, newValue) {
+ final limited = LengthLimitingTextInputFormatter(
+ 6,
+ ).formatEditUpdate(oldValue, newValue);
+ // TextField does not call onChanged when the length limit
+ // rejects another digit. Still acknowledge that attempt.
+ if (invalid &&
+ newValue.text.length > oldValue.text.length &&
+ limited.text == oldValue.text) {
+ onRejectedInput();
+ }
+ return limited;
+ }),
+ ],
+ decoration: const InputDecoration(
+ labelText: 'Desktop code, six digits',
+ border: InputBorder.none,
+ ),
+ onChanged: onChanged,
+ onSubmitted: (_) => onSubmitted(),
+ ),
+ ),
+ ),
+ ExcludeSemantics(
+ child: Row(
+ children: [
+ for (var index = 0; index < 6; index++) ...[
+ if (index > 0)
+ SizedBox(width: index == 3 ? Grid.twelve : Grid.half),
+ Expanded(
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: enabled ? () => focusCell(index) : null,
+ child: AnimatedContainer(
+ key: Key('pairing-code-cell-$index'),
+ duration: reducedMotion
+ ? Duration.zero
+ : Duration(milliseconds: invalid ? 280 : 150),
+ curve: Curves.easeOut,
+ padding: const EdgeInsets.symmetric(vertical: Grid.xs),
+ decoration: BoxDecoration(
+ color: context._onboardingInputSurface,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: invalid
+ ? context._onboardingErrorInk
+ : focus.hasFocus && activeIndex == index
+ ? context._onboardingInk
+ : context.colors.primary.withValues(alpha: 0.15),
+ ),
+ ),
+ child: ClipRect(
+ child: AnimatedSwitcher(
+ duration: reducedMotion
+ ? Duration.zero
+ : const Duration(milliseconds: 150),
+ switchInCurve: Curves.easeOut,
+ switchOutCurve: Curves.easeIn,
+ transitionBuilder: (child, animation) =>
+ FadeTransition(
+ opacity: animation,
+ child: AnimatedBuilder(
+ animation: animation,
+ builder: (context, child) =>
+ Transform.translate(
+ offset: Offset(
+ 0,
+ reducedMotion
+ ? 0
+ : 8 * (1 - animation.value),
+ ),
+ child: child,
+ ),
+ child: child,
+ ),
+ ),
+ child: Text(
+ index < editing.text.length
+ ? editing.text[index]
+ : ' ',
+ key: ValueKey(
+ index < editing.text.length
+ ? editing.text[index]
+ : '',
+ ),
+ textAlign: TextAlign.center,
+ style: context.textTheme.displaySmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ color: context._onboardingInk,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/mobile/lib/features/pairing/pairing_page/pairing_error_shake.dart b/mobile/lib/features/pairing/pairing_page/pairing_error_shake.dart
new file mode 100644
index 00000000000..d7cc17aaecb
--- /dev/null
+++ b/mobile/lib/features/pairing/pairing_page/pairing_error_shake.dart
@@ -0,0 +1,52 @@
+part of '../pairing_page.dart';
+
+class _PairingErrorShake extends HookConsumerWidget {
+ const _PairingErrorShake({required this.revision, required this.child});
+ final int revision;
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final reducedMotion = MediaQuery.disableAnimationsOf(context);
+ final shake = useAnimationController(
+ duration: const Duration(milliseconds: 280),
+ );
+ useEffect(() {
+ if (reducedMotion || revision == 0) {
+ shake.reset();
+ } else if (revision > 0) {
+ shake.forward(from: 0);
+ }
+ return null;
+ }, [revision, reducedMotion]);
+ final shakeOffset = useMemoized(
+ () => TweenSequence([
+ // Transitions.dev stops: 0%, 28.57%, 57.14%, 78.57%, 100%.
+ // Each leg uses the same easing curve independently.
+ for (final (begin, end, weight) in [
+ (0.0, 6.0, 80.0),
+ (6.0, -6.0, 80.0),
+ (-6.0, 4.0, 60.0),
+ (4.0, 0.0, 60.0),
+ ])
+ TweenSequenceItem(
+ tween: Tween(
+ begin: begin,
+ end: end,
+ ).chain(CurveTween(curve: const Cubic(0.22, 1, 0.36, 1))),
+ weight: weight,
+ ),
+ ]).animate(shake),
+ [shake],
+ );
+ return AnimatedBuilder(
+ animation: shakeOffset,
+ builder: (context, child) => Transform.translate(
+ key: const Key('pairing-error-shake'),
+ offset: Offset(reducedMotion ? 0 : shakeOffset.value, 0),
+ child: child,
+ ),
+ child: child,
+ );
+ }
+}
diff --git a/mobile/lib/features/pairing/pairing_page/sas_verification_view.dart b/mobile/lib/features/pairing/pairing_page/sas_verification_view.dart
new file mode 100644
index 00000000000..6eab484de7e
--- /dev/null
+++ b/mobile/lib/features/pairing/pairing_page/sas_verification_view.dart
@@ -0,0 +1,280 @@
+part of '../pairing_page.dart';
+
+/// SAS verification screen shown during NIP-AB pairing.
+class _SasVerificationView extends HookConsumerWidget {
+ final String sasCode;
+ final Future Function(String)? verifyDesktopCode;
+ final bool confirmed;
+ final bool sendsIdentityToDesktop;
+ final String biometricLabel;
+ final String? errorMessage;
+ final ValueChanged onProtectionChanged;
+ final VoidCallback onConfirm;
+ final VoidCallback onDeny;
+
+ const _SasVerificationView({
+ super.key,
+ required this.sasCode,
+ this.verifyDesktopCode,
+ required this.confirmed,
+ required this.sendsIdentityToDesktop,
+ required this.biometricLabel,
+ required this.errorMessage,
+ required this.onProtectionChanged,
+ required this.onConfirm,
+ required this.onDeny,
+ });
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final controller = useTextEditingController();
+ final enteredCode = useValueListenable(controller).text;
+ final mismatch = useState(false);
+ final checkingCode = useState(false);
+ final rejectedCode = useRef(null);
+ final verificationError = useState(null);
+ final shakeRevision = useState(0);
+ final codeAccepted = useState(false);
+ final confirmationSent = useRef(false);
+ useEffect(() {
+ if (!confirmed) confirmationSent.value = false;
+ return null;
+ }, [confirmed, errorMessage]);
+
+ void confirm({bool? protection}) {
+ if (confirmed || confirmationSent.value) return;
+ confirmationSent.value = true;
+ if (protection != null) onProtectionChanged(protection);
+ onConfirm();
+ }
+
+ void checkCode(String value) {
+ if (confirmed || codeAccepted.value || checkingCode.value) return;
+ verificationError.value = null;
+ if (verifyDesktopCode != null) {
+ mismatch.value = false;
+ if (value.length != 6) return;
+ if (value == rejectedCode.value) {
+ mismatch.value = true;
+ shakeRevision.value++;
+ unawaited(errorHaptic());
+ return;
+ }
+ checkingCode.value = true;
+ unawaited(() async {
+ try {
+ final accepted = await verifyDesktopCode!(value);
+ if (!context.mounted) return;
+ if (accepted) {
+ codeAccepted.value = true;
+ FocusScope.of(context).unfocus();
+ } else {
+ rejectedCode.value = value;
+ mismatch.value = true;
+ shakeRevision.value++;
+ unawaited(errorHaptic());
+ }
+ } catch (_) {
+ if (context.mounted) {
+ verificationError.value = 'Couldn’t check the code. Try again.';
+ }
+ } finally {
+ if (context.mounted) checkingCode.value = false;
+ }
+ }());
+ return;
+ }
+ mismatch.value = value.length == 6 && value != sasCode;
+ if (mismatch.value) {
+ shakeRevision.value++;
+ unawaited(errorHaptic());
+ } else if (value.length == 6) {
+ codeAccepted.value = true;
+ FocusScope.of(context).unfocus();
+ if (sendsIdentityToDesktop) confirm();
+ }
+ }
+
+ useEffect(() {
+ if (verifyDesktopCode == null) return null;
+ var active = true;
+ // Negotiation can arrive after the user typed a code under legacy SAS
+ // rules. Re-evaluate that input against the source-only desktop code.
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!active || !context.mounted) return;
+ rejectedCode.value = null;
+ mismatch.value = false;
+ codeAccepted.value = false;
+ checkCode(controller.text);
+ });
+ return () => active = false;
+ }, [verifyDesktopCode != null]);
+
+ final showProtection = codeAccepted.value && !sendsIdentityToDesktop;
+
+ final verificationContent = Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (showProtection) ...[
+ Icon(
+ biometricLabel == 'Use Face ID'
+ ? LucideIcons.scanFace
+ : LucideIcons.fingerprint,
+ size: 64,
+ color: context._onboardingInk,
+ ),
+ const SizedBox(height: Grid.sm),
+ Text(
+ 'Protect your identity',
+ textAlign: TextAlign.center,
+ style: context.textTheme.headlineSmall?.copyWith(
+ color: context._onboardingInk,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: Grid.xxs),
+ Text(
+ '$biometricLabel to confirm it’s you before sending your Buzz identity to another device.',
+ textAlign: TextAlign.center,
+ style: context.textTheme.bodyMedium?.copyWith(
+ color: context._onboardingMutedInk,
+ ),
+ ),
+ ] else if (codeAccepted.value) ...[
+ Icon(
+ LucideIcons.shieldCheck,
+ size: 64,
+ color: context._onboardingInk,
+ ),
+ const SizedBox(height: Grid.sm),
+ Text('Code confirmed', style: context.textTheme.headlineSmall),
+ ] else ...[
+ Text(
+ 'Enter pairing code',
+ textAlign: TextAlign.center,
+ style: context.textTheme.headlineSmall?.copyWith(
+ color: context._onboardingInk,
+ fontWeight: FontWeight.w600,
+ letterSpacing: -0.4,
+ ),
+ ),
+ const SizedBox(height: Grid.md),
+ _PairingErrorShake(
+ revision: shakeRevision.value,
+ child: _PairingCodeEntry(
+ controller: controller,
+ enabled: !confirmed && !checkingCode.value,
+ invalid: mismatch.value,
+ onChanged: checkCode,
+ onSubmitted: () => checkCode(enteredCode),
+ onRejectedInput: () => checkCode(controller.text),
+ ),
+ ),
+ const SizedBox(height: Grid.xxs),
+ AnimatedOpacity(
+ key: const Key('pairing-code-error'),
+ opacity: mismatch.value ? 1 : 0,
+ duration: const Duration(milliseconds: 280),
+ curve: Curves.easeOut,
+ child: ExcludeSemantics(
+ excluding: !mismatch.value,
+ child: Semantics(
+ liveRegion: true,
+ child: Text(
+ 'Check the desktop code and try again.',
+ textAlign: TextAlign.center,
+ style: context.textTheme.bodySmall?.copyWith(
+ color: context._onboardingInk,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ if (errorMessage != null || verificationError.value != null) ...[
+ const SizedBox(height: Grid.xs),
+ Text(
+ errorMessage ?? verificationError.value!,
+ textAlign: TextAlign.center,
+ style: context.textTheme.bodySmall?.copyWith(
+ color: context._onboardingInk,
+ ),
+ ),
+ ],
+ ],
+ );
+
+ final verificationActions = confirmed
+ ? Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ BuzzLoadingIndicator(
+ size: 24,
+ color: context._onboardingInk,
+ semanticLabel: 'Connecting',
+ ),
+ const SizedBox(width: Grid.twelve),
+ Text(
+ 'Confirmed — waiting for desktop',
+ style: context.textTheme.bodySmall?.copyWith(
+ color: context._onboardingMutedInk,
+ ),
+ ),
+ ],
+ )
+ : SizedBox(
+ width: double.infinity,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ if (showProtection ||
+ (codeAccepted.value && errorMessage != null)) ...[
+ FilledButton(
+ style: context._onboardingButtonStyle,
+ onPressed: () =>
+ confirm(protection: showProtection ? true : null),
+ child: Text(showProtection ? biometricLabel : 'Try again'),
+ ),
+ const SizedBox(height: Grid.xxs),
+ ],
+ TextButton(
+ style: context._onboardingSecondaryButtonStyle.copyWith(
+ minimumSize: const WidgetStatePropertyAll(
+ Size.fromHeight(48),
+ ),
+ ),
+ onPressed: showProtection
+ ? () => confirm(protection: false)
+ : onDeny,
+ child: Text(showProtection ? 'Skip' : 'Cancel'),
+ ),
+ ],
+ ),
+ );
+
+ return Column(
+ children: [
+ Expanded(
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final verticalPadding = Grid.sm * 2;
+ final minimumContentHeight =
+ constraints.maxHeight > verticalPadding
+ ? constraints.maxHeight - verticalPadding
+ : 0.0;
+ return SingleChildScrollView(
+ padding: const EdgeInsets.symmetric(vertical: Grid.sm),
+ child: ConstrainedBox(
+ constraints: BoxConstraints(minHeight: minimumContentHeight),
+ child: Center(child: verificationContent),
+ ),
+ );
+ },
+ ),
+ ),
+ verificationActions,
+ const SizedBox(height: Grid.sm),
+ ],
+ );
+ }
+}
diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart
index ecf61dd1060..510158344b4 100644
--- a/mobile/lib/features/pairing/pairing_provider.dart
+++ b/mobile/lib/features/pairing/pairing_provider.dart
@@ -8,6 +8,7 @@ import 'package:http/http.dart' as http;
import 'package:nostr/nostr.dart' as nostr;
import '../../shared/auth/auth.dart';
+import '../../shared/community/paired_community_landing.dart';
import '../../shared/crypto/ecdh.dart';
import '../../shared/crypto/nip44.dart';
import '../../shared/relay/relay.dart';
@@ -15,6 +16,9 @@ import '../../shared/security/sensitive_action_authorizer.dart';
import 'pairing_crypto.dart';
import 'pairing_socket.dart';
+part 'pairing_provider_helpers.dart';
+part 'pairing_state.dart';
+
/// HTTP client used by [PairingNotifier] for the validation request.
final pairingHttpClientProvider = Provider((ref) {
final client = http.Client();
@@ -22,58 +26,6 @@ final pairingHttpClientProvider = Provider((ref) {
return client;
});
-enum PairingStatus {
- idle,
- connecting,
- confirmingSas,
- transferring,
- storing,
- success,
- error,
-}
-
-class PairingState {
- final PairingStatus status;
- final String? errorMessage;
- final String? sasCode;
- final bool userConfirmedSas;
- final bool sendsIdentityToDesktop;
- final bool protectSensitiveActions;
- final bool authorizationInProgress;
-
- const PairingState({
- this.status = PairingStatus.idle,
- this.errorMessage,
- this.sasCode,
- this.userConfirmedSas = false,
- this.sendsIdentityToDesktop = false,
- this.protectSensitiveActions = true,
- this.authorizationInProgress = false,
- });
-
- PairingState copyWith({
- PairingStatus? status,
- String? errorMessage,
- String? sasCode,
- bool? userConfirmedSas,
- bool? sendsIdentityToDesktop,
- bool? protectSensitiveActions,
- bool? authorizationInProgress,
- bool clearErrorMessage = false,
- }) => PairingState(
- status: status ?? this.status,
- errorMessage: clearErrorMessage ? null : errorMessage ?? this.errorMessage,
- sasCode: sasCode ?? this.sasCode,
- userConfirmedSas: userConfirmedSas ?? this.userConfirmedSas,
- sendsIdentityToDesktop:
- sendsIdentityToDesktop ?? this.sendsIdentityToDesktop,
- protectSensitiveActions:
- protectSensitiveActions ?? this.protectSensitiveActions,
- authorizationInProgress:
- authorizationInProgress ?? this.authorizationInProgress,
- );
-}
-
typedef PairingSocketFactory =
PairingSocket Function({
required String wsUrl,
@@ -98,6 +50,9 @@ class PairingNotifier extends Notifier {
RelaySocket? _validationSocket;
PairingSocket? _socket;
Timer? _sessionTimeout;
+ Completer? _codeResult;
+ String? _codeRequestId;
+ int _codeRequestSequence = 0;
Community? _identityExportCommunity;
bool _identityExportBiometricOnly = false;
@@ -182,6 +137,41 @@ class PairingNotifier extends Notifier {
if (_sasConfirmReceived) unawaited(_continueAfterSas());
}
+ /// Check user input with the source; never compare a QR-derived SAS when the
+ /// source has negotiated an independent desktop-only code.
+ Future verifyDesktopCode(String code) async {
+ if (!state.requiresDesktopCode ||
+ state.status != PairingStatus.confirmingSas ||
+ _codeResult != null ||
+ !RegExp(r'^\d{6}$').hasMatch(code)) {
+ return false;
+ }
+ final result = Completer();
+ _codeResult = result;
+ _codeRequestId = '${++_codeRequestSequence}';
+ _publishEvent(
+ kind: 24134,
+ content: _encryptMessage({
+ 'type': 'code-submit',
+ 'code': code,
+ 'request_id': _codeRequestId,
+ }),
+ tags: [
+ ['p', _sourcePubkey!],
+ ],
+ );
+ try {
+ // Keep this logical attempt pending until its response or session cleanup.
+ // A local deadline cannot undo a guess already consumed by the source.
+ return await result.future;
+ } finally {
+ if (identical(_codeResult, result)) {
+ _codeResult = null;
+ _codeRequestId = null;
+ }
+ }
+ }
+
void setProtectSensitiveActions(bool value) {
if (state.status != PairingStatus.confirmingSas ||
state.sendsIdentityToDesktop ||
@@ -323,6 +313,9 @@ class PairingNotifier extends Notifier {
}
void _cleanup() {
+ if (_codeResult?.isCompleted == false) _codeResult!.complete(false);
+ _codeResult = null;
+ _codeRequestId = null;
_pairingGeneration++;
_validationSocket?.dispose();
_validationSocket = null;
@@ -331,6 +324,7 @@ class PairingNotifier extends Notifier {
_socket?.dispose();
_socket = null;
_processedEventIds.clear();
+ _publishedEventIds.clear();
_sasConfirmReceived = false;
_userConfirmedSas = false;
_pendingPayload = null;
@@ -356,6 +350,7 @@ class PairingNotifier extends Notifier {
int _pairingGeneration = 0;
DateTime? _identityExportAuthorizedAt;
Map? _pendingPayload; // buffered until user confirms SAS
+ final Set _publishedEventIds = {};
final Set _processedEventIds = {}; // NIP-AB §Duplicate Event Handling
Future _pairNipAb(String uri) async {
@@ -419,6 +414,7 @@ class PairingNotifier extends Notifier {
// 7. Build and send the offer event.
final offerContent = _encryptMessage({
'type': 'offer',
+ if (!_sendIdentityToSource) 'confirmation': 'desktop-code-v1',
'version': 1,
'session_id': bytesToHex(_sessionId!),
});
@@ -468,41 +464,27 @@ class PairingNotifier extends Notifier {
}
}
- static String _friendlyErrorMessage(Object error) {
- final message = error.toString();
- if (message.contains('SocketException') ||
- message.contains('Connection refused') ||
- message.contains('Network is unreachable') ||
- message.contains('No route to host') ||
- message.contains('Failed to connect')) {
- return 'Could not reach the pairing relay. Check your internet '
- 'connection and VPN, then try again.';
- }
- if (error is PairingAuthException) {
- return 'The pairing relay rejected authentication. Try creating a new '
- 'pairing code.';
- }
- if (error is StateError ||
- message.contains('Null check operator used on a null value')) {
- return 'Pairing stopped because of an internal error. Please try again.';
- }
- if (message.contains('HandshakeException') ||
- message.contains('CERTIFICATE_VERIFY_FAILED')) {
- return 'Secure connection failed. Check your network settings '
- 'and try again.';
- }
- if (message.contains('TimeoutException') || message.contains('timed out')) {
- return 'Connection timed out. Check your internet connection and '
- 'try again.';
- }
- return 'Connection failed. Please check your internet connection '
- 'and try again.';
- }
-
void _handleRelayMessage(List data) {
if (data.isEmpty) return;
final type = data[0] as String;
+ final rejected =
+ type == 'OK' &&
+ data.length >= 3 &&
+ _publishedEventIds.remove(data[1]) &&
+ data[2] == false;
+ final closed = type == 'CLOSED' && data.length >= 2 && data[1] == 'pair';
+ if ((rejected || closed) &&
+ state.status != PairingStatus.success &&
+ state.status != PairingStatus.error) {
+ _cleanup();
+ state = const PairingState(
+ status: PairingStatus.error,
+ errorMessage:
+ 'Pairing couldn’t continue. Scan a new desktop QR code and try again.',
+ );
+ return;
+ }
if (type == 'EVENT' && data.length >= 3) {
final eventJson = data[2] as Map;
_handlePairingEvent(eventJson);
@@ -555,6 +537,28 @@ class PairingNotifier extends Notifier {
final msgType = msg['type'] as String?;
switch (msgType) {
+ case 'desktop-code':
+ if (!_sendIdentityToSource &&
+ state.status == PairingStatus.confirmingSas &&
+ !_sasConfirmReceived) {
+ state = state.copyWith(requiresDesktopCode: true);
+ }
+ _processedEventIds.add(eventId);
+ case 'code-rejected':
+ if (state.requiresDesktopCode &&
+ msg['request_id'] == _codeRequestId &&
+ _codeResult?.isCompleted == false) {
+ _codeResult!.complete(false);
+ if (msg['remaining_attempts'] == 0) {
+ _cleanup();
+ state = const PairingState(
+ status: PairingStatus.error,
+ errorMessage:
+ 'Too many incorrect codes. Scan a new desktop QR code and try again.',
+ );
+ }
+ }
+ _processedEventIds.add(eventId);
case 'sas-confirm':
_handleSasConfirm(msg);
_processedEventIds.add(eventId); // record after successful processing
@@ -602,6 +606,9 @@ class PairingNotifier extends Notifier {
}
_sasConfirmReceived = true;
+ if (state.requiresDesktopCode && _codeResult?.isCompleted == false) {
+ _codeResult!.complete(true);
+ }
// If the user already tapped "Codes Match", complete the transition now
// that the transcript hash is verified.
@@ -728,6 +735,7 @@ class PairingNotifier extends Notifier {
// Validate relay URL to prevent SSRF via private network addresses.
_validateRelayUrl(relayUrl);
+ state = state.copyWith(destinationRelayUrl: relayUrl);
// Validate credentials against the relay via NIP-42 WS handshake.
final credentialValidator = _credentialValidator ?? _validateCredentials;
@@ -761,7 +769,8 @@ class PairingNotifier extends Notifier {
}
_cleanup();
- state = const PairingState(status: PairingStatus.success);
+ state = state.copyWith(status: PairingStatus.success);
+ ref.read(pairedCommunityLandingProvider.notifier).request(community);
} catch (e) {
if (pairingGeneration != _pairingGeneration ||
state.status != PairingStatus.storing ||
@@ -831,6 +840,7 @@ class PairingNotifier extends Notifier {
createdAt: createdAt,
);
+ _publishedEventIds.add(event.id);
_socket?.publishEvent(event.toMap());
}
@@ -862,7 +872,11 @@ class PairingNotifier extends Notifier {
.read(authProvider.notifier)
.authenticateWithCommunity(community);
if (generation != _pairingGeneration) return;
- state = const PairingState(status: PairingStatus.success);
+ state = PairingState(
+ status: PairingStatus.success,
+ destinationRelayUrl: community.relayUrl,
+ );
+ ref.read(pairedCommunityLandingProvider.notifier).request(community);
} on FormatException catch (e) {
if (generation != _pairingGeneration) return;
state = PairingState(
@@ -944,48 +958,6 @@ class PairingNotifier extends Notifier {
sensitiveActionPolicy: SensitiveActionPolicy.disabledByUser,
);
}
-
- void _validateRelayUrl(String url) {
- final uri = Uri.parse(url);
-
- if (!kDebugMode && uri.scheme != 'https') {
- throw const FormatException('Relay URL must use HTTPS');
- }
- if (uri.scheme != 'http' && uri.scheme != 'https') {
- throw FormatException('Invalid URL scheme: ${uri.scheme}');
- }
-
- final host = uri.host.toLowerCase();
- if (host == 'localhost' || host == '127.0.0.1' || host == '::1') {
- if (!kDebugMode) {
- throw const FormatException('Relay URL cannot target localhost');
- }
- return;
- }
-
- final ip = Uri.tryParse('http://$host')?.host ?? host;
- if (_isPrivateHost(ip)) {
- throw const FormatException(
- 'Relay URL cannot target private network addresses',
- );
- }
- }
-
- static bool _isPrivateHost(String host) {
- final parts = host.split('.');
- if (parts.length != 4) return false;
- final octets = parts.map(int.tryParse).toList();
- if (octets.any((o) => o == null)) return false;
-
- final a = octets[0]!;
- final b = octets[1]!;
-
- if (a == 10) return true;
- if (a == 172 && b >= 16 && b <= 31) return true;
- if (a == 192 && b == 168) return true;
- if (a == 169 && b == 254) return true;
- return false;
- }
}
final pairingProvider = NotifierProvider(
diff --git a/mobile/lib/features/pairing/pairing_provider_helpers.dart b/mobile/lib/features/pairing/pairing_provider_helpers.dart
new file mode 100644
index 00000000000..30d11932a53
--- /dev/null
+++ b/mobile/lib/features/pairing/pairing_provider_helpers.dart
@@ -0,0 +1,74 @@
+part of 'pairing_provider.dart';
+
+String _friendlyErrorMessage(Object error) {
+ final message = error.toString();
+ if (message.contains('SocketException') ||
+ message.contains('Connection refused') ||
+ message.contains('Network is unreachable') ||
+ message.contains('No route to host') ||
+ message.contains('Failed to connect')) {
+ return 'Could not reach the pairing relay. Check your internet '
+ 'connection and VPN, then try again.';
+ }
+ if (error is PairingAuthException) {
+ return 'The pairing relay rejected authentication. Try creating a new '
+ 'pairing code.';
+ }
+ if (error is StateError ||
+ message.contains('Null check operator used on a null value')) {
+ return 'Pairing stopped because of an internal error. Please try again.';
+ }
+ if (message.contains('HandshakeException') ||
+ message.contains('CERTIFICATE_VERIFY_FAILED')) {
+ return 'Secure connection failed. Check your network settings '
+ 'and try again.';
+ }
+ if (message.contains('TimeoutException') || message.contains('timed out')) {
+ return 'Connection timed out. Check your internet connection and '
+ 'try again.';
+ }
+ return 'Connection failed. Please check your internet connection '
+ 'and try again.';
+}
+
+void _validateRelayUrl(String url) {
+ final uri = Uri.parse(url);
+
+ if (!kDebugMode && uri.scheme != 'https') {
+ throw const FormatException('Relay URL must use HTTPS');
+ }
+ if (uri.scheme != 'http' && uri.scheme != 'https') {
+ throw FormatException('Invalid URL scheme: ${uri.scheme}');
+ }
+
+ final host = uri.host.toLowerCase();
+ if (host == 'localhost' || host == '127.0.0.1' || host == '::1') {
+ if (!kDebugMode) {
+ throw const FormatException('Relay URL cannot target localhost');
+ }
+ return;
+ }
+
+ final ip = Uri.tryParse('http://$host')?.host ?? host;
+ if (_isPrivateHost(ip)) {
+ throw const FormatException(
+ 'Relay URL cannot target private network addresses',
+ );
+ }
+}
+
+bool _isPrivateHost(String host) {
+ final parts = host.split('.');
+ if (parts.length != 4) return false;
+ final octets = parts.map(int.tryParse).toList();
+ if (octets.any((o) => o == null)) return false;
+
+ final a = octets[0]!;
+ final b = octets[1]!;
+
+ if (a == 10) return true;
+ if (a == 172 && b >= 16 && b <= 31) return true;
+ if (a == 192 && b == 168) return true;
+ if (a == 169 && b == 254) return true;
+ return false;
+}
diff --git a/mobile/lib/features/pairing/pairing_state.dart b/mobile/lib/features/pairing/pairing_state.dart
new file mode 100644
index 00000000000..c6b16d67614
--- /dev/null
+++ b/mobile/lib/features/pairing/pairing_state.dart
@@ -0,0 +1,61 @@
+part of 'pairing_provider.dart';
+
+enum PairingStatus {
+ idle,
+ connecting,
+ confirmingSas,
+ transferring,
+ storing,
+ success,
+ error,
+}
+
+class PairingState {
+ final PairingStatus status;
+ final String? errorMessage;
+ final String? sasCode;
+ final bool userConfirmedSas;
+ final bool sendsIdentityToDesktop;
+ final bool protectSensitiveActions;
+ final bool authorizationInProgress;
+ final String? destinationRelayUrl;
+ final bool requiresDesktopCode;
+
+ const PairingState({
+ this.status = PairingStatus.idle,
+ this.errorMessage,
+ this.sasCode,
+ this.userConfirmedSas = false,
+ this.sendsIdentityToDesktop = false,
+ this.protectSensitiveActions = true,
+ this.authorizationInProgress = false,
+ this.destinationRelayUrl,
+ this.requiresDesktopCode = false,
+ });
+
+ PairingState copyWith({
+ PairingStatus? status,
+ String? errorMessage,
+ String? sasCode,
+ bool? userConfirmedSas,
+ bool? sendsIdentityToDesktop,
+ bool? protectSensitiveActions,
+ bool? authorizationInProgress,
+ String? destinationRelayUrl,
+ bool? requiresDesktopCode,
+ bool clearErrorMessage = false,
+ }) => PairingState(
+ status: status ?? this.status,
+ requiresDesktopCode: requiresDesktopCode ?? this.requiresDesktopCode,
+ destinationRelayUrl: destinationRelayUrl ?? this.destinationRelayUrl,
+ errorMessage: clearErrorMessage ? null : errorMessage ?? this.errorMessage,
+ sasCode: sasCode ?? this.sasCode,
+ userConfirmedSas: userConfirmedSas ?? this.userConfirmedSas,
+ sendsIdentityToDesktop:
+ sendsIdentityToDesktop ?? this.sendsIdentityToDesktop,
+ protectSensitiveActions:
+ protectSensitiveActions ?? this.protectSensitiveActions,
+ authorizationInProgress:
+ authorizationInProgress ?? this.authorizationInProgress,
+ );
+}
diff --git a/mobile/lib/shared/community/community_avatar.dart b/mobile/lib/shared/community/community_avatar.dart
new file mode 100644
index 00000000000..20bfddd2a9b
--- /dev/null
+++ b/mobile/lib/shared/community/community_avatar.dart
@@ -0,0 +1,53 @@
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import '../theme/theme.dart';
+import '../widgets/avatar_image.dart';
+import 'community_icon_provider.dart';
+
+/// The community icon shared by navigation and community loading surfaces.
+class CommunityAvatar extends ConsumerWidget {
+ final String? name;
+ final String? relayUrl;
+ final double size;
+
+ const CommunityAvatar({
+ super.key,
+ required this.name,
+ this.relayUrl,
+ this.size = 40,
+ });
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final trimmedName = name?.trim();
+ final initial = trimmedName != null && trimmedName.isNotEmpty
+ ? trimmedName.characters.first.toUpperCase()
+ : '?';
+ final relay = relayUrl;
+ final iconUrl = relay == null
+ ? null
+ : ref.watch(communityIconPresentationProvider(relay));
+
+ return ClipRSuperellipse(
+ borderRadius: BorderRadius.circular(Radii.card * size / 40),
+ child: ColoredBox(
+ color: context.colors.primaryContainer,
+ child: SizedBox.square(
+ dimension: size,
+ child: AvatarImageContent(
+ imageUrl: iconUrl,
+ fallback: Text(
+ initial,
+ textScaler: TextScaler.noScaling,
+ style: context.textTheme.labelMedium?.copyWith(
+ fontSize: size * 0.38,
+ color: context.colors.onPrimaryContainer,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/shared/community/community_loading_surface.dart b/mobile/lib/shared/community/community_loading_surface.dart
new file mode 100644
index 00000000000..cf210c17f6b
--- /dev/null
+++ b/mobile/lib/shared/community/community_loading_surface.dart
@@ -0,0 +1,38 @@
+import 'package:flutter/material.dart';
+import '../theme/theme.dart';
+import '../widgets/buzz_loading_indicator.dart';
+import 'community_avatar.dart';
+
+/// Holds the incoming community at the switch animation's centered position.
+class CommunityLoadingSurface extends StatelessWidget {
+ const CommunityLoadingSurface({super.key, this.name, this.relayUrl});
+
+ final String? name;
+ final String? relayUrl;
+
+ @override
+ Widget build(BuildContext context) => ColoredBox(
+ color: context.colors.surface,
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ if (relayUrl != null)
+ Center(
+ child: CommunityAvatar(name: name, relayUrl: relayUrl, size: 132),
+ ),
+ Positioned(
+ top: MediaQuery.sizeOf(context).height / 2 + 66 + Grid.md,
+ left: 0,
+ right: 0,
+ child: Center(
+ child: BuzzLoadingIndicator(
+ size: 18,
+ color: context.colors.onSecondaryContainer,
+ semanticLabel: name == null ? 'Connecting' : 'Loading $name',
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+}
diff --git a/mobile/lib/shared/community/paired_community_landing.dart b/mobile/lib/shared/community/paired_community_landing.dart
new file mode 100644
index 00000000000..0acd6b99a92
--- /dev/null
+++ b/mobile/lib/shared/community/paired_community_landing.dart
@@ -0,0 +1,21 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+import 'community.dart';
+
+/// A one-shot request to reveal an imported community through its avatar.
+final pairedCommunityLandingProvider =
+ NotifierProvider(
+ PairedCommunityLanding.new,
+ );
+
+/// Keeps the handoff alive while authentication replaces the onboarding page.
+class PairedCommunityLanding extends Notifier {
+ @override
+ Community? build() => null;
+
+ /// Requests the shared loading and avatar flight after a successful import.
+ void request(Community community) => state = community;
+
+ /// Consumes the request once the destination page owns the transition.
+ void clear() => state = null;
+}
diff --git a/mobile/lib/shared/error_haptic.dart b/mobile/lib/shared/error_haptic.dart
new file mode 100644
index 00000000000..97cd74c3dbe
--- /dev/null
+++ b/mobile/lib/shared/error_haptic.dart
@@ -0,0 +1,12 @@
+import 'package:flutter/services.dart';
+
+/// Plays the platform's rejection/error feedback without affecting the action.
+Future errorHaptic() async {
+ try {
+ await const MethodChannel('buzz/haptics').invokeMethod('error');
+ } on PlatformException {
+ // Feedback is optional on devices without a haptic engine.
+ } on MissingPluginException {
+ await HapticFeedback.heavyImpact();
+ }
+}
diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart
index a694aa1beb7..eca1cbd77e0 100644
--- a/mobile/test/features/channels/channels_page_test.dart
+++ b/mobile/test/features/channels/channels_page_test.dart
@@ -1,3 +1,5 @@
+import 'package:buzz/features/pairing/pairing_provider.dart';
+import 'package:buzz/shared/community/paired_community_landing.dart';
import 'dart:async';
import '../profile/presence_snapshot_test.dart'
@@ -1774,6 +1776,95 @@ void main() {
);
});
+ for (final reduceMotion in [false, true]) {
+ testWidgets(
+ 'paired community waits then uses the community landing (reduced motion: $reduceMotion)',
+ (tester) async {
+ debugDefaultTargetPlatformOverride = TargetPlatform.android;
+ addTearDown(() => debugDefaultTargetPlatformOverride = null);
+ tester.view.physicalSize = const Size(390, 844);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(tester.view.resetPhysicalSize);
+ addTearDown(tester.view.resetDevicePixelRatio);
+ final community = Community(
+ id: 'alpha',
+ name: 'Alpha',
+ relayUrl: 'wss://alpha.example.com',
+ addedAt: DateTime(2025),
+ );
+ final notifier = _FakeCommunityListNotifier([community]);
+ final loaded = Completer>();
+ await tester.pumpWidget(
+ buildTestable(
+ disableAnimations: reduceMotion,
+ overrides: [
+ pairingProvider.overrideWith(_CompletedPairingNotifier.new),
+ channelsProvider.overrideWith(
+ () => _FakeNotifier(testChannels, load: () => loaded.future),
+ ),
+ communityListProvider.overrideWith(() => notifier),
+ activeCommunityProvider.overrideWith((ref) async => community),
+ ],
+ ),
+ );
+ await tester.pump();
+ final context = tester.element(find.byType(ChannelsPage));
+ final container = ProviderScope.containerOf(context);
+ // A successful add-community pairing still has a route to dismiss.
+ final navigator = Navigator.of(context);
+ unawaited(
+ navigator.push(
+ MaterialPageRoute(
+ builder: (_) => const Scaffold(body: Text('Pairing complete')),
+ ),
+ ),
+ );
+ await tester.pump();
+ container
+ .read(pairedCommunityLandingProvider.notifier)
+ .request(community);
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 400));
+ expect(find.byKey(const Key('community-flying-avatar')), findsNothing);
+ navigator.pop();
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 400));
+ await tester.pump();
+ await tester.pump();
+ final avatar = find.byKey(const Key('community-flying-avatar'));
+ expect(avatar, findsOneWidget);
+ expect(tester.getCenter(avatar), const Offset(195, 422));
+ expect(
+ find.byKey(const Key('community-switch-loading')),
+ findsOneWidget,
+ );
+ expect(container.read(pairedCommunityLandingProvider), isNull);
+ expect(notifier.switchedIds, isEmpty);
+ loaded.complete(
+ testChannels.where((channel) => channel.channelType != 'dm').toList(),
+ );
+ for (var i = 0; i < 12; i++) {
+ await tester.pump();
+ }
+ await tester.pump(const Duration(milliseconds: 450));
+ for (var i = 0; i < 6; i++) {
+ await tester.pump();
+ }
+ if (!reduceMotion) {
+ await tester.pump(const Duration(milliseconds: 260));
+ expect(tester.getCenter(avatar).dy, lessThan(422));
+ }
+ await tester.pumpAndSettle();
+ expect(avatar, findsNothing);
+ expect(find.byKey(const Key('community-switch-loading')), findsNothing);
+ expect(notifier.switchedIds, isEmpty);
+ expect(container.read(pairingProvider).status, PairingStatus.idle);
+ expect(tester.takeException(), isNull);
+ debugDefaultTargetPlatformOverride = null;
+ },
+ );
+ }
+
for (final nativeHeader in [false, true]) {
for (final reduceMotion in [false, true]) {
testWidgets(
@@ -3827,3 +3918,8 @@ class _SwitchingUserCache extends UserCacheNotifier {
loaded.complete(true);
}
}
+
+class _CompletedPairingNotifier extends PairingNotifier {
+ @override
+ PairingState build() => const PairingState(status: PairingStatus.success);
+}
diff --git a/mobile/test/features/pairing/pairing_code_entry_test.dart b/mobile/test/features/pairing/pairing_code_entry_test.dart
new file mode 100644
index 00000000000..b2f118d067d
--- /dev/null
+++ b/mobile/test/features/pairing/pairing_code_entry_test.dart
@@ -0,0 +1,493 @@
+import 'dart:async';
+import 'package:buzz/features/pairing/pairing_page.dart';
+import 'package:buzz/features/pairing/pairing_provider.dart';
+import 'package:buzz/shared/theme/theme.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+class _EntryNotifier extends PairingNotifier {
+ _EntryNotifier({
+ this.code = '123456',
+ this.recovery = false,
+ this.remote = false,
+ });
+ final bool remote;
+ final submissions = [];
+ Completer? verification;
+ @override
+ Future verifyDesktopCode(String value) {
+ submissions.add(value);
+ verification = Completer();
+ return verification!.future;
+ }
+
+ void completePairing() =>
+ state = state.copyWith(status: PairingStatus.success);
+ final String code;
+ final bool recovery;
+ int confirmations = 0;
+ bool? chosenProtection;
+
+ @override
+ PairingState build() => PairingState(
+ status: PairingStatus.confirmingSas,
+ sasCode: code,
+ sendsIdentityToDesktop: recovery,
+ requiresDesktopCode: remote,
+ );
+
+ @override
+ void confirmSas() {
+ confirmations++;
+ state = state.copyWith(userConfirmedSas: true);
+ }
+
+ @override
+ void setProtectSensitiveActions(bool value) {
+ chosenProtection = value;
+ super.setProtectSensitiveActions(value);
+ }
+
+ void biometricCancelled() {
+ state = state.copyWith(
+ userConfirmedSas: false,
+ errorMessage: 'Biometric confirmation cancelled. Try again or skip.',
+ );
+ }
+}
+
+void main() {
+ final field = find.byKey(const Key('pairing-code-input'));
+
+ Future<_EntryNotifier> showEntry(
+ WidgetTester tester, {
+ bool reducedMotion = false,
+ String code = '123456',
+ bool recovery = false,
+ bool remote = false,
+ }) async {
+ final notifier = _EntryNotifier(
+ code: code,
+ recovery: recovery,
+ remote: remote,
+ );
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [pairingProvider.overrideWith(() => notifier)],
+ child: MaterialApp(
+ theme: AppTheme.light(),
+ builder: (context, child) => MediaQuery(
+ data: MediaQuery.of(
+ context,
+ ).copyWith(disableAnimations: reducedMotion),
+ child: child!,
+ ),
+ home: const PairingPage(),
+ ),
+ ),
+ );
+ return notifier;
+ }
+
+ testWidgets(
+ 'remote verification waits for desktop and does not trust the derived SAS',
+ (tester) async {
+ final notifier = await showEntry(tester, remote: true);
+ await tester.enterText(field, '123456');
+ await tester.pump();
+ expect(find.text('Protect your identity'), findsNothing);
+ expect(notifier.submissions, ['123456']);
+ notifier.verification!.complete(false);
+ await tester.pumpAndSettle();
+ expect(find.text('Protect your identity'), findsNothing);
+ tester.testTextInput.updateEditingValue(
+ const TextEditingValue(text: '1234567'),
+ );
+ await tester.pump();
+ expect(notifier.submissions, ['123456']);
+ await tester.enterText(field, '654321');
+ await tester.pump();
+ notifier.verification!.complete(true);
+ await tester.pumpAndSettle();
+ expect(find.text('Protect your identity'), findsOneWidget);
+ expect(notifier.confirmations, 0);
+ await tester.tap(find.text('Skip'));
+ await tester.pump();
+ expect(notifier.confirmations, 1);
+ },
+ );
+
+ testWidgets(
+ 'successful pushed pairing resets after dismissal and can reopen',
+ (tester) async {
+ final notifier = _EntryNotifier();
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [pairingProvider.overrideWith(() => notifier)],
+ child: MaterialApp(
+ theme: AppTheme.light(),
+ home: Builder(
+ builder: (context) => Scaffold(
+ body: TextButton(
+ onPressed: () => Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => const PairingPage(addingCommunity: true),
+ ),
+ ),
+ child: const Text('Open pairing'),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.tap(find.text('Open pairing'));
+ await tester.pumpAndSettle();
+ notifier.completePairing();
+ await tester.pump();
+ await tester.pumpAndSettle();
+ expect(find.byType(PairingPage), findsNothing);
+ await tester.tap(find.text('Open pairing'));
+ await tester.pumpAndSettle();
+ expect(find.text('Scan a QR code'), findsOneWidget);
+ expect(find.byKey(const Key('pairing-community-loading')), findsNothing);
+ },
+ );
+
+ testWidgets(
+ 'starts empty with no extra confirmation or biometrics controls',
+ (tester) async {
+ await showEntry(tester);
+ expect(find.text('Enter pairing code'), findsOneWidget);
+ expect(tester.widget(field).controller!.text, isEmpty);
+ expect(find.text('123456'), findsNothing);
+ expect(find.text('123 456'), findsNothing);
+ expect(find.text('Continue'), findsNothing);
+ expect(find.text('Use biometrics'), findsNothing);
+ for (var index = 0; index < 6; index++) {
+ expect(find.byKey(Key('pairing-code-cell-$index')), findsOneWidget);
+ }
+ },
+ );
+
+ testWidgets('wrong code shakes only the digit boxes with one haptic', (
+ tester,
+ ) async {
+ final notifier = await showEntry(tester);
+ final haptics =