Refine mobile pairing confirmation

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-16 09:26:15 +01:00
parent d8281b9c93
commit 0bd6baa0af
6 changed files with 437 additions and 134 deletions
+14
View File
@@ -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);
+1
View File
@@ -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
+29 -6
View File
@@ -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)")
}
}
+155 -118
View File
@@ -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;
}
+14 -4
View File
@@ -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"