mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Refine mobile pairing confirmation
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -41,6 +41,20 @@ files:
|
||||
signing always win)
|
||||
- `mobile/android/worktree.properties` (read by the debug build type only)
|
||||
|
||||
Android developers can keep a stable local test identity that takes precedence
|
||||
over the generated worktree values by creating the gitignored
|
||||
`mobile/android/AppOverrides.properties`:
|
||||
|
||||
```properties
|
||||
appName=Buzz Pairing
|
||||
applicationIdSuffix=.device_pairing_e2e1
|
||||
```
|
||||
|
||||
These values are consumed by the debug build type only. The standard
|
||||
`just mobile-build-android` command can still be used; regenerating
|
||||
`worktree.properties` does not overwrite `AppOverrides.properties`. Release
|
||||
and profile builds keep the production `Buzz` name and application ID.
|
||||
|
||||
For direct Xcode / Android Studio / `flutter run` development, run
|
||||
`./scripts/mobile-worktree-overrides.sh` from the repo root once per branch
|
||||
switch to refresh the display label (the install identity never changes);
|
||||
|
||||
@@ -7,6 +7,7 @@ gradle-wrapper.jar
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
/worktree.properties
|
||||
/AppOverrides.properties
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
|
||||
@@ -30,6 +30,15 @@ val worktreeProps =
|
||||
Properties().apply {
|
||||
if (worktreePropsFile.isFile) worktreePropsFile.inputStream().use { load(it) }
|
||||
}
|
||||
// Optional gitignored developer overrides are loaded after the generated
|
||||
// worktree values. They are consumed only by the debug build type below, so a
|
||||
// long-lived device test build can keep a stable, descriptive local identity
|
||||
// without changing release/profile or being overwritten by the worktree script.
|
||||
val appOverridesFile = rootProject.file("AppOverrides.properties")
|
||||
val appOverrides =
|
||||
Properties().apply {
|
||||
if (appOverridesFile.isFile) appOverridesFile.inputStream().use { load(it) }
|
||||
}
|
||||
val worktreeLabel = worktreeProps.getProperty("label")?.takeIf { it.isNotBlank() }
|
||||
if (worktreeLabel != null && !worktreeLabel.matches(Regex("""[A-Za-z0-9._-]+"""))) {
|
||||
throw GradleException(
|
||||
@@ -39,10 +48,22 @@ if (worktreeLabel != null && !worktreeLabel.matches(Regex("""[A-Za-z0-9._-]+""")
|
||||
}
|
||||
val worktreeIdSuffix =
|
||||
worktreeProps.getProperty("applicationIdSuffix")?.takeIf { it.isNotBlank() }
|
||||
if (worktreeIdSuffix != null && !worktreeIdSuffix.matches(Regex("""\.[a-z][a-z0-9_]*"""))) {
|
||||
val debugIdSuffix =
|
||||
appOverrides.getProperty("applicationIdSuffix")?.takeIf { it.isNotBlank() }
|
||||
?: worktreeIdSuffix
|
||||
if (debugIdSuffix != null && !debugIdSuffix.matches(Regex("""\.[a-z][a-z0-9_]*"""))) {
|
||||
throw GradleException(
|
||||
"worktree.properties applicationIdSuffix must match \\.[a-z][a-z0-9_]*, got: " +
|
||||
worktreeIdSuffix,
|
||||
"debug applicationIdSuffix must match \\.[a-z][a-z0-9_]*, got: " +
|
||||
debugIdSuffix,
|
||||
)
|
||||
}
|
||||
val debugAppName = appOverrides.getProperty("appName")?.takeIf { it.isNotBlank() }
|
||||
if (
|
||||
debugAppName != null &&
|
||||
!debugAppName.matches(Regex("""[A-Za-z0-9][A-Za-z0-9 ._()\-]{0,39}"""))
|
||||
) {
|
||||
throw GradleException(
|
||||
"debug appName must be 1-40 resource-safe characters, got: " + debugAppName,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -109,10 +130,12 @@ android {
|
||||
debug {
|
||||
// Only debug builds take the worktree identity; release/profile
|
||||
// keep the production applicationId and label.
|
||||
if (worktreeIdSuffix != null) {
|
||||
applicationIdSuffix = worktreeIdSuffix
|
||||
if (debugIdSuffix != null) {
|
||||
applicationIdSuffix = debugIdSuffix
|
||||
}
|
||||
if (worktreeLabel != null) {
|
||||
if (debugAppName != null) {
|
||||
resValue("string", "app_name", debugAppName)
|
||||
} else if (worktreeLabel != null) {
|
||||
resValue("string", "app_name", "Buzz ($worktreeLabel)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,32 +86,22 @@ class PairingPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
final isVerifyingSas = pairingState.status == PairingStatus.confirmingSas;
|
||||
final themedSystemOverlayStyle =
|
||||
(context.theme.brightness == Brightness.dark
|
||||
? SystemUiOverlayStyle.light
|
||||
: SystemUiOverlayStyle.dark)
|
||||
.copyWith(statusBarColor: Colors.transparent);
|
||||
final onboardingSystemOverlayStyle = SystemUiOverlayStyle.dark.copyWith(
|
||||
statusBarColor: Colors.transparent,
|
||||
);
|
||||
final pairingAppBar = addingCommunity
|
||||
? AppBar(
|
||||
foregroundColor: isVerifyingSas
|
||||
? context.colors.onSurface
|
||||
: _onboardingInk,
|
||||
systemOverlayStyle: isVerifyingSas
|
||||
? themedSystemOverlayStyle
|
||||
: SystemUiOverlayStyle.dark.copyWith(
|
||||
statusBarColor: Colors.transparent,
|
||||
),
|
||||
foregroundColor: _onboardingInk,
|
||||
systemOverlayStyle: onboardingSystemOverlayStyle,
|
||||
leading: IconButton(
|
||||
icon: const Icon(LucideIcons.arrowLeft),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: Text(
|
||||
identityRecoveryOnly ? 'Send to Desktop' : 'Add Community',
|
||||
style: isVerifyingSas
|
||||
? null
|
||||
: context.textTheme.titleMedium?.copyWith(
|
||||
color: _onboardingInk,
|
||||
),
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
color: _onboardingInk,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
@@ -119,30 +109,33 @@ class PairingPage extends HookConsumerWidget {
|
||||
final pairingScaffold = isVerifyingSas
|
||||
? AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
key: const Key('pairing-sas-system-overlay'),
|
||||
value: themedSystemOverlayStyle,
|
||||
child: Scaffold(
|
||||
backgroundColor: context.colors.surface,
|
||||
appBar: pairingAppBar,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.sm),
|
||||
child: _SasVerificationView(
|
||||
sasCode: pairingState.sasCode ?? '------',
|
||||
confirmed: pairingState.userConfirmedSas,
|
||||
sendsIdentityToDesktop: pairingState.sendsIdentityToDesktop,
|
||||
protectSensitiveActions:
|
||||
pairingState.protectSensitiveActions,
|
||||
biometricLabel: biometricProtectionLabel(
|
||||
defaultTargetPlatform,
|
||||
enrolledBiometrics.value ?? const [],
|
||||
value: onboardingSystemOverlayStyle,
|
||||
child: _OnboardingBackground(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.sm),
|
||||
child: _SasVerificationView(
|
||||
sasCode: pairingState.sasCode ?? '------',
|
||||
confirmed: pairingState.userConfirmedSas,
|
||||
sendsIdentityToDesktop:
|
||||
pairingState.sendsIdentityToDesktop,
|
||||
protectSensitiveActions:
|
||||
pairingState.protectSensitiveActions,
|
||||
biometricLabel: biometricProtectionLabel(
|
||||
defaultTargetPlatform,
|
||||
enrolledBiometrics.value ?? const [],
|
||||
),
|
||||
errorMessage: pairingState.errorMessage,
|
||||
onProtectionChanged: (value) => ref
|
||||
.read(pairingProvider.notifier)
|
||||
.setProtectSensitiveActions(value),
|
||||
onConfirm: () =>
|
||||
ref.read(pairingProvider.notifier).confirmSas(),
|
||||
onDeny: () =>
|
||||
ref.read(pairingProvider.notifier).denySas(),
|
||||
),
|
||||
errorMessage: pairingState.errorMessage,
|
||||
onProtectionChanged: (value) => ref
|
||||
.read(pairingProvider.notifier)
|
||||
.setProtectSensitiveActions(value),
|
||||
onConfirm: () =>
|
||||
ref.read(pairingProvider.notifier).confirmSas(),
|
||||
onDeny: () => ref.read(pairingProvider.notifier).denySas(),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -182,6 +175,7 @@ class PairingPage extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
final appSurface = PopScope(
|
||||
key: const Key('pairing-pop-scope'),
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) {
|
||||
ref.read(pairingProvider.notifier).reset();
|
||||
@@ -206,6 +200,10 @@ 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;
|
||||
@@ -230,65 +228,68 @@ class _SasVerificationView extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
final verificationContent = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Spacer(flex: 2),
|
||||
|
||||
Icon(LucideIcons.shieldCheck, size: 56, color: context.colors.primary),
|
||||
const SizedBox(height: Grid.sm),
|
||||
|
||||
Text('Verify Security Code', style: context.textTheme.headlineSmall),
|
||||
const SizedBox(height: Grid.xs),
|
||||
|
||||
Text(
|
||||
confirmed
|
||||
? 'Waiting for desktop to confirm...'
|
||||
: 'Does your desktop app show this code?',
|
||||
'Confirm desktop code',
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
style: context.textTheme.headlineSmall?.copyWith(
|
||||
color: _onboardingInk,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.4,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Grid.lg),
|
||||
|
||||
// Large SAS code display
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: context.colors.primary.withValues(alpha: 0.3),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'${sasCode.substring(0, 3)} ${sasCode.substring(3)}',
|
||||
style: context.textTheme.displayMedium?.copyWith(
|
||||
fontFamily: 'GeistMono',
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 8,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Grid.lg),
|
||||
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
sendsIdentityToDesktop
|
||||
? 'This sends your full Buzz identity to the desktop\nand grants it permanent access. Only confirm a\ndesktop you trust and a recovery you started.'
|
||||
: 'You are about to transfer your Buzz identity\nto this device. Only confirm if you initiated\nthis pairing from your desktop.',
|
||||
? '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.',
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: _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: Colors.white.withValues(alpha: 0.7),
|
||||
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: _onboardingInk,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Grid.sm),
|
||||
|
||||
if (!sendsIdentityToDesktop)
|
||||
CheckboxListTile(
|
||||
key: const Key('protect-sensitive-actions-checkbox'),
|
||||
@@ -296,12 +297,25 @@ class _SasVerificationView extends StatelessWidget {
|
||||
onChanged: confirmed
|
||||
? null
|
||||
: (value) => onProtectionChanged(value ?? false),
|
||||
activeColor: _onboardingInk,
|
||||
checkColor: _onboardingCtaLabel,
|
||||
side: const BorderSide(color: _onboardingInk),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(biometricLabel),
|
||||
subtitle: const Text('For secure actions'),
|
||||
title: Text(
|
||||
biometricLabel,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: _onboardingInk,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'For secure actions',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: _onboardingMutedInk,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (errorMessage != null) ...[
|
||||
const SizedBox(height: Grid.xs),
|
||||
Text(
|
||||
@@ -312,51 +326,74 @@ class _SasVerificationView extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
const SizedBox(height: Grid.lg),
|
||||
|
||||
// Confirm / Deny buttons
|
||||
if (confirmed)
|
||||
Row(
|
||||
final verificationActions = confirmed
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
BuzzLoadingIndicator(
|
||||
size: 24,
|
||||
color: context.colors.primary,
|
||||
color: _onboardingInk,
|
||||
semanticLabel: 'Connecting',
|
||||
),
|
||||
const SizedBox(width: Grid.twelve),
|
||||
Text(
|
||||
'Confirmed — waiting for desktop',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
color: _onboardingMutedInk,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onDeny,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
label: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.sm),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
style: _onboardingButtonStyle,
|
||||
onPressed: onConfirm,
|
||||
icon: const Icon(LucideIcons.check),
|
||||
label: const Text('Codes Match'),
|
||||
label: const Text('Codes match'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
TextButton(
|
||||
style: _onboardingSecondaryButtonStyle.copyWith(
|
||||
minimumSize: const WidgetStatePropertyAll(
|
||||
Size.fromHeight(48),
|
||||
),
|
||||
),
|
||||
onPressed: onDeny,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
const Spacer(flex: 3),
|
||||
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),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:buzz/features/pairing/pairing_page.dart';
|
||||
import 'package:buzz/features/pairing/pairing_provider.dart';
|
||||
import 'package:buzz/shared/community/community.dart';
|
||||
@@ -65,7 +66,7 @@ void main() {
|
||||
expect(overlay.value.statusBarColor, Colors.transparent);
|
||||
});
|
||||
|
||||
testWidgets('uses light status-bar icons for dark-theme SAS verification', (
|
||||
testWidgets('uses the onboarding surface for dark-theme SAS verification', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
@@ -81,9 +82,61 @@ void main() {
|
||||
find.byKey(const Key('pairing-sas-system-overlay')),
|
||||
);
|
||||
|
||||
expect(overlay.value.statusBarIconBrightness, Brightness.light);
|
||||
expect(overlay.value.statusBarIconBrightness, Brightness.dark);
|
||||
expect(overlay.value.statusBarColor, Colors.transparent);
|
||||
expect(find.text('Verify Security Code'), findsOneWidget);
|
||||
final background = tester.widget<DecoratedBox>(
|
||||
find.byKey(const Key('pairing-onboarding-background')),
|
||||
);
|
||||
final backgroundDecoration = background.decoration as BoxDecoration;
|
||||
final backgroundGradient =
|
||||
backgroundDecoration.gradient! as LinearGradient;
|
||||
expect(backgroundGradient.colors, const [
|
||||
Color(0xFFD7D72E),
|
||||
Color(0xFFD7E7F6),
|
||||
]);
|
||||
expect(
|
||||
tester.widget<Scaffold>(find.byType(Scaffold)).backgroundColor,
|
||||
Colors.transparent,
|
||||
);
|
||||
expect(find.text('Confirm desktop code'), findsOneWidget);
|
||||
expect(
|
||||
find.text(
|
||||
'Make sure the six-digit code matches on both devices. Your Buzz identity will transfer to this device.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Does your desktop app show this code?'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('uses Cancel as the only visible SAS exit', (tester) async {
|
||||
final notifier = _ConfirmingSasPairingNotifier();
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [pairingProvider.overrideWith(() => notifier)],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.dark(),
|
||||
home: const PairingPage(addingCommunity: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(AppBar), findsNothing);
|
||||
expect(find.text('Add Community'), findsNothing);
|
||||
expect(find.byIcon(LucideIcons.arrowLeft), findsNothing);
|
||||
expect(find.byKey(const Key('pairing-pop-scope')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Cancel'));
|
||||
expect(notifier.denied, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('keeps the add-community header outside SAS', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(child: const PairingPage(addingCommunity: true)),
|
||||
);
|
||||
|
||||
expect(find.byType(AppBar), findsOneWidget);
|
||||
expect(find.text('Add Community'), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.arrowLeft), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('reveals pairing code field and connect action', (
|
||||
@@ -326,7 +379,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('recovery SAS warns about permanent desktop access', (
|
||||
testWidgets('recovery SAS puts permanent desktop access in the subtitle', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
@@ -342,7 +395,171 @@ void main() {
|
||||
|
||||
expect(find.textContaining('full Buzz identity'), findsOneWidget);
|
||||
expect(find.textContaining('permanent access'), findsOneWidget);
|
||||
expect(find.text('Codes Match'), findsOneWidget);
|
||||
expect(find.textContaining('started this recovery'), findsOneWidget);
|
||||
expect(find.text('Codes match'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('matches the onboarding visual system and SAS action layout', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
pairingProvider.overrideWith(() => _ConfirmingSasPairingNotifier()),
|
||||
],
|
||||
child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byIcon(LucideIcons.shieldCheck), findsNothing);
|
||||
expect(find.text('Confirm desktop code'), findsOneWidget);
|
||||
expect(
|
||||
find.text(
|
||||
'Make sure the six-digit code matches on both devices. Your Buzz identity will transfer to this device.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Does your desktop app show this code?'), findsNothing);
|
||||
|
||||
final digitFinders = [
|
||||
for (var index = 1; index <= 6; index++)
|
||||
find.byKey(Key('pairing-sas-code-digit-$index')),
|
||||
];
|
||||
for (final digitFinder in digitFinders) {
|
||||
expect(tester.getSize(digitFinder).width, 54);
|
||||
expect(
|
||||
tester.widget<Container>(digitFinder).padding,
|
||||
const EdgeInsets.symmetric(vertical: Grid.xs),
|
||||
);
|
||||
}
|
||||
|
||||
const onboardingInk = Color(0xFF111111);
|
||||
const onboardingMutedInk = Color(0xB3111111);
|
||||
const onboardingCtaLabel = Color(0xFFD7E6F0);
|
||||
final theme = AppTheme.dark();
|
||||
final protectionTile = tester.widget<CheckboxListTile>(
|
||||
find.byKey(const Key('protect-sensitive-actions-checkbox')),
|
||||
);
|
||||
expect(protectionTile.activeColor, onboardingInk);
|
||||
expect(protectionTile.checkColor, onboardingCtaLabel);
|
||||
expect(protectionTile.side?.color, onboardingInk);
|
||||
expect((protectionTile.title as Text).style?.color, onboardingInk);
|
||||
expect(
|
||||
(protectionTile.subtitle as Text).style?.color,
|
||||
onboardingMutedInk,
|
||||
);
|
||||
final firstDigitContainer = tester.widget<Container>(digitFinders.first);
|
||||
final firstDigitDecoration =
|
||||
firstDigitContainer.decoration! as BoxDecoration;
|
||||
expect(firstDigitDecoration.color, Colors.white.withValues(alpha: 0.7));
|
||||
expect(
|
||||
(firstDigitDecoration.border! as Border).top.color,
|
||||
theme.colorScheme.primary.withValues(alpha: 0.15),
|
||||
);
|
||||
final firstDigitText = tester.widget<Text>(
|
||||
find.descendant(of: digitFinders.first, matching: find.text('1')),
|
||||
);
|
||||
expect(firstDigitText.style?.fontFamily, 'Inter');
|
||||
expect(
|
||||
firstDigitText.style?.fontSize,
|
||||
theme.textTheme.displaySmall?.fontSize,
|
||||
);
|
||||
expect(firstDigitText.style?.fontSize, greaterThanOrEqualTo(36));
|
||||
expect(firstDigitText.style?.fontWeight, FontWeight.w600);
|
||||
expect(firstDigitText.style?.fontFeatures, isNull);
|
||||
expect(firstDigitText.style?.color, onboardingInk);
|
||||
|
||||
final firstDigit = tester.getTopLeft(digitFinders[0]);
|
||||
final secondDigit = tester.getTopLeft(digitFinders[1]);
|
||||
final thirdDigit = tester.getTopLeft(digitFinders[2]);
|
||||
final fourthDigit = tester.getTopLeft(digitFinders[3]);
|
||||
expect(secondDigit.dx - firstDigit.dx, 60);
|
||||
expect(fourthDigit.dx - thirdDigit.dx, 68);
|
||||
|
||||
final confirmFinder = find.widgetWithText(FilledButton, 'Codes match');
|
||||
final cancelFinder = find.widgetWithText(TextButton, 'Cancel');
|
||||
final confirmButton = tester.widget<FilledButton>(confirmFinder);
|
||||
final cancelButton = tester.widget<TextButton>(cancelFinder);
|
||||
expect(
|
||||
confirmButton.style?.backgroundColor?.resolve(<WidgetState>{}),
|
||||
onboardingInk,
|
||||
);
|
||||
expect(
|
||||
confirmButton.style?.foregroundColor?.resolve(<WidgetState>{}),
|
||||
onboardingCtaLabel,
|
||||
);
|
||||
expect(
|
||||
confirmButton.style?.shape?.resolve(<WidgetState>{}),
|
||||
isA<StadiumBorder>(),
|
||||
);
|
||||
expect(
|
||||
cancelButton.style?.backgroundColor?.resolve(<WidgetState>{}),
|
||||
onboardingInk.withValues(alpha: 0.1),
|
||||
);
|
||||
expect(
|
||||
cancelButton.style?.foregroundColor?.resolve(<WidgetState>{}),
|
||||
onboardingInk,
|
||||
);
|
||||
expect(
|
||||
cancelButton.style?.shape?.resolve(<WidgetState>{}),
|
||||
isA<StadiumBorder>(),
|
||||
);
|
||||
final confirmTopLeft = tester.getTopLeft(confirmFinder);
|
||||
final cancelTopLeft = tester.getTopLeft(cancelFinder);
|
||||
final scaffoldWidth = tester.getSize(find.byType(Scaffold)).width;
|
||||
expect(confirmTopLeft.dy, lessThan(cancelTopLeft.dy));
|
||||
expect(confirmTopLeft.dx, cancelTopLeft.dx);
|
||||
expect(confirmTopLeft.dx, Grid.sm);
|
||||
expect(tester.getSize(confirmFinder).width, scaffoldWidth - Grid.sm * 2);
|
||||
expect(tester.getSize(cancelFinder).width, scaffoldWidth - Grid.sm * 2);
|
||||
expect(tester.getSize(confirmFinder).height, 48);
|
||||
expect(tester.getSize(cancelFinder).height, 48);
|
||||
expect(
|
||||
find.textContaining('Only confirm if you started this pairing.'),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
tester.getBottomLeft(find.byType(Scaffold)).dy -
|
||||
tester.getBottomLeft(cancelFinder).dy,
|
||||
Grid.sm,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('keeps SAS actions above the keyboard on small screens', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(360, 560);
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 200);
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
pairingProvider.overrideWith(() => _ConfirmingSasPairingNotifier()),
|
||||
],
|
||||
child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()),
|
||||
),
|
||||
);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.byType(SingleChildScrollView), findsOneWidget);
|
||||
final cancelFinder = find.widgetWithText(TextButton, 'Cancel');
|
||||
expect(tester.getBottomLeft(cancelFinder).dy, 560 - 200 - Grid.sm);
|
||||
|
||||
await tester.drag(
|
||||
find.byType(SingleChildScrollView),
|
||||
const Offset(0, -100),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('Confirm desktop code'), findsOneWidget);
|
||||
expect(find.textContaining('matches on both devices'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining('Buzz identity will transfer'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Codes match'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -438,6 +655,7 @@ class _ConfirmingSasPairingNotifier extends Notifier<PairingState>
|
||||
_ConfirmingSasPairingNotifier({this.sendsIdentityToDesktop = false});
|
||||
|
||||
final bool sendsIdentityToDesktop;
|
||||
bool denied = false;
|
||||
|
||||
@override
|
||||
PairingState build() => PairingState(
|
||||
@@ -463,5 +681,5 @@ class _ConfirmingSasPairingNotifier extends Notifier<PairingState>
|
||||
void setProtectSensitiveActions(bool value) {}
|
||||
|
||||
@override
|
||||
void denySas() {}
|
||||
void denySas() => denied = true;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
# display-only label sanitized to [A-Za-z0-9._-].
|
||||
# - the tracked iOS/Android build files keep production identity, only
|
||||
# consume the overrides in debug configurations, and let a developer's
|
||||
# AppOverrides.xcconfig take precedence over the worktree defaults.
|
||||
# AppOverrides.xcconfig / AppOverrides.properties take precedence over the
|
||||
# worktree defaults.
|
||||
# - scripts/mobile-worktree-clean.sh only ever targets suffixed installs,
|
||||
# never the production app ids.
|
||||
set -euo pipefail
|
||||
@@ -157,6 +158,11 @@ grep -q 'resValue("string", "app_name", "Buzz")' "$gradle" \
|
||||
grep -q 'worktreeLabel.matches' "$gradle" \
|
||||
&& pass "Gradle validates the worktree label before use" \
|
||||
|| fail "Gradle must validate the worktree label against a safe pattern"
|
||||
grep -q 'AppOverrides.properties' "$gradle" \
|
||||
&& grep -q 'debugAppName' "$gradle" \
|
||||
&& grep -q 'debugIdSuffix' "$gradle" \
|
||||
&& pass "Android developer overrides can replace the debug name and identity" \
|
||||
|| fail "Gradle must support debug-only AppOverrides.properties"
|
||||
|
||||
# Extract a brace-balanced block: everything from the first line matching $2
|
||||
# to the line where its braces close. Unlike a /start/,/}/ awk range, nested
|
||||
@@ -181,9 +187,10 @@ printf '%s\n' "$sneaky" | extract_block - 'release \{' | grep -q 'worktreeSneaky
|
||||
|| fail "release-block extractor must not stop at the first nested close brace"
|
||||
|
||||
# The worktree suffix/label must only appear inside the debug build type.
|
||||
extract_block "$gradle" 'buildTypes \{' | extract_block - 'release \{' | grep -q 'worktree' \
|
||||
&& fail "release build type must not reference worktree identity" \
|
||||
|| pass "release build type does not reference worktree identity"
|
||||
release_block="$(extract_block "$gradle" 'buildTypes \{' | extract_block - 'release \{')"
|
||||
printf '%s\n' "$release_block" | grep -Eq 'worktree|debugAppName|debugIdSuffix' \
|
||||
&& fail "release build type must not reference debug identity overrides" \
|
||||
|| pass "release build type does not reference debug identity overrides"
|
||||
|
||||
git -C "$repo_root" check-ignore -q mobile/ios/Flutter/WorktreeOverrides.xcconfig \
|
||||
&& pass "iOS override file is gitignored" \
|
||||
@@ -191,6 +198,9 @@ git -C "$repo_root" check-ignore -q mobile/ios/Flutter/WorktreeOverrides.xcconfi
|
||||
git -C "$repo_root" check-ignore -q mobile/android/worktree.properties \
|
||||
&& pass "Android override file is gitignored" \
|
||||
|| fail "mobile/android/worktree.properties must be gitignored"
|
||||
git -C "$repo_root" check-ignore -q mobile/android/AppOverrides.properties \
|
||||
&& pass "Android developer override file is gitignored" \
|
||||
|| fail "mobile/android/AppOverrides.properties must be gitignored"
|
||||
grep -Eq '^\s+\./scripts/mobile-worktree-overrides\.sh$' "$repo_root/Justfile" \
|
||||
&& pass "just mobile-dev applies the worktree identity" \
|
||||
|| fail "Justfile mobile-dev must run scripts/mobile-worktree-overrides.sh"
|
||||
|
||||
Reference in New Issue
Block a user