sync_settings_ui: fix Connect Firebase hanging forever on a bad password
Some checks failed
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
sync_settings_ui tests / test (push) Has been cancelled

connectWithPassword() had no try/catch around firebaseFactory(), unlike
connectWithGoogle() right next to it. A thrown FirebaseAuthError (wrong
password, 400 from identitytoolkit) escaped uncaught out of the button's
async onPressed, so _busy/_status never updated and the screen sat on
"Signing in..." forever with no error shown -- reproduced live via the
browser console (confirmed "Uncaught Error" immediately after the 400,
not a hung Future).

Also reports real progress stages ("Saving account...", "Signing in to
Firebase...") instead of one static string, clears the bad password on a
confirmed rejection (previously only the null-client branch did, letting a
background sync tick keep retrying it), and surfaces the actual failure
reason in the status text instead of a generic "rejected" message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XATAXuMzpYggWyTBPGyiSN
This commit is contained in:
KRZYSZTOF RUDNICKI 2026-08-25 14:38:00 +02:00
parent d22417f41b
commit 26d7507fff
6 changed files with 180 additions and 16 deletions

View File

@ -1,3 +1,5 @@
import 'dart:developer';
import 'package:crdt_sync/crdt_sync.dart';
/// Outcome of a Firebase connect attempt, distinguishing why it did or did
@ -127,22 +129,77 @@ class FirebaseSyncController {
/// Stores [email]/[password] and signs in immediately, so a typo surfaces
/// here instead of as a silent background failure on the next sync tick.
///
/// [onProgress], if given, is called with a human-readable stage name as
/// each awaited step starts, so a caller can show the user what is
/// actually happening instead of one static "Signing in..." the whole
/// time -- and, if it stalls, which step it stalled on.
///
/// Wraps [firebaseFactory] the same way [connectWithGoogle] already does:
/// broader than Exception on purpose, matching every app-local copy -- a
/// missing platform binding raises a FlutterError, which is an Error, not
/// an Exception -- letting it escape left the button disabled and the
/// screen stuck on "Signing in..." forever. That fix existed for the
/// Google path but was never mirrored here.
Future<FirebaseConnectResult> connectWithPassword({
required String email,
required String password,
void Function(String stage)? onProgress,
}) async {
onProgress?.call('Saving account…');
await accountSaver(FirebaseAccount(email: email, password: password));
final client = await firebaseFactory();
if (client == null) {
try {
onProgress?.call('Signing in to Firebase…');
final client = await firebaseFactory();
if (client == null) {
log(
'connectWithPassword: rejected (null client) for $email',
name: 'FirebaseSyncController',
level: 900,
);
await accountClearer();
return const FirebaseConnectResult(
outcome: FirebaseConnectOutcome.rejected,
);
}
log(
'connectWithPassword: connected as $email',
name: 'FirebaseSyncController',
);
return FirebaseConnectResult(
outcome: FirebaseConnectOutcome.connected,
email: email,
);
} on FirebaseAuthError catch (error, stackTrace) {
log(
'connectWithPassword: rejected for $email',
name: 'FirebaseSyncController',
level: 900,
error: error,
stackTrace: stackTrace,
);
// A confirmed-bad password shouldn't linger in the keystore for a
// background sync tick to keep retrying against.
await accountClearer();
return const FirebaseConnectResult(
return FirebaseConnectResult(
outcome: FirebaseConnectOutcome.rejected,
message: error.message,
);
} on Object catch (error, stackTrace) {
log(
'connectWithPassword: failed for $email',
name: 'FirebaseSyncController',
level: 1000,
error: error,
stackTrace: stackTrace,
);
// Not cleared: this could be a transient failure (network, wrapper
// hiccup), not proof the credentials themselves are wrong.
return FirebaseConnectResult(
outcome: FirebaseConnectOutcome.failed,
message: '$error',
);
}
return FirebaseConnectResult(
outcome: FirebaseConnectOutcome.connected,
email: email,
);
}
/// Signs in by picking a Google account -- the one-tap path. Callers must

View File

@ -58,13 +58,13 @@ class _SyncSettingsScreenState extends State<SyncSettingsScreen> {
setState(() => _status = 'Enter the sync account email and password.');
return;
}
setState(() {
_busy = true;
_status = 'Signing in…';
});
setState(() => _busy = true);
final result = await _firebase.connectWithPassword(
email: email,
password: password,
onProgress: (stage) {
if (mounted) setState(() => _status = stage);
},
);
if (!mounted) return;
_applyConnectResult(result, clearPasswordOnSuccess: true);
@ -94,14 +94,15 @@ class _SyncSettingsScreenState extends State<SyncSettingsScreen> {
_firebaseConnected = result.outcome == FirebaseConnectOutcome.connected;
_status = switch (result.outcome) {
FirebaseConnectOutcome.connected => 'Connected to Firebase.',
FirebaseConnectOutcome.rejected => 'Firebase rejected that account.',
FirebaseConnectOutcome.rejected => result.message != null
? 'Firebase rejected that account: ${result.message}'
: 'Firebase rejected that account.',
FirebaseConnectOutcome.cancelled => 'Google sign-in was cancelled.',
FirebaseConnectOutcome.signedInButNotPersisted =>
'Signed in, but this device did not save the session - it will '
'sync over GitHub after a restart. Try connecting again.',
FirebaseConnectOutcome.wrongAccount => result.message,
FirebaseConnectOutcome.failed =>
'Google sign-in failed: ${result.message}',
FirebaseConnectOutcome.failed => 'Sign-in failed: ${result.message}',
};
});
}

View File

@ -5,7 +5,7 @@ description: >-
injected closures -- no keystore or platform plugin access lives in this
package itself.
publish_to: 'none'
version: 0.3.0
version: 0.4.0
environment:
sdk: ^3.12.2

View File

@ -98,6 +98,70 @@ void main() {
expect(cleared, isTrue);
},
);
test(
'clears the account and reports rejected with the error message when '
'firebaseFactory throws FirebaseAuthError -- reproduces the screen '
'getting stuck on "Signing in..." forever, since nothing here used '
'to catch this',
() async {
var cleared = false;
final controller = FirebaseSyncController(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async => cleared = true,
sessionProbe: () async => false,
firebaseFactory: () async => throw FirebaseAuthError('wrong password'),
);
final result = await controller.connectWithPassword(
email: 'a@b.com',
password: 'wrong',
);
expect(result.outcome, FirebaseConnectOutcome.rejected);
expect(result.message, 'wrong password');
expect(cleared, isTrue);
},
);
test(
'reports failed with the stringified error, and does not clear the '
'account, on any other failure -- a possibly-transient error is not '
'proof the credentials are wrong',
() async {
var cleared = false;
final controller = FirebaseSyncController(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async => cleared = true,
sessionProbe: () async => false,
firebaseFactory: () async => throw StateError('boom'),
);
final result = await controller.connectWithPassword(
email: 'a@b.com',
password: 'pw',
);
expect(result.outcome, FirebaseConnectOutcome.failed);
expect(result.message, contains('boom'));
expect(cleared, isFalse);
},
);
test('reports progress stages in order on success', () async {
final stages = <String>[];
final controller = FirebaseSyncController(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => true,
firebaseFactory: () async => _fakeClient(),
);
await controller.connectWithPassword(
email: 'a@b.com',
password: 'pw',
onProgress: stages.add,
);
expect(stages, ['Saving account…', 'Signing in to Firebase…']);
});
});
group('connectWithGoogle', () {

View File

@ -116,7 +116,7 @@ void main() {
await tester.tap(find.text('Sign in with Google'));
await tester.pumpAndSettle();
expect(find.textContaining('Google sign-in failed'), findsOneWidget);
expect(find.textContaining('Sign-in failed'), findsOneWidget);
});
testWidgets(

View File

@ -166,6 +166,48 @@ void main() {
expect(find.text('Firebase rejected that account.'), findsOneWidget);
});
testWidgets(
'connect with password does not get stuck on "Signing in..." when '
'the factory throws -- reproduces the reported hang, where the '
'button stayed disabled and the status text never changed',
(tester) async {
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async =>
throw FirebaseAuthError('bad creds'),
),
),
);
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextField, 'Sync account email'),
'a@b.com',
);
await tester.enterText(
find.widgetWithText(TextField, 'Sync account password'),
'wrong',
);
await tester.tap(find.text('Connect Firebase'));
await tester.pumpAndSettle();
expect(find.text('Signing in to Firebase…'), findsNothing);
expect(
find.text('Firebase rejected that account: bad creds'),
findsOneWidget,
);
final button = tester.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Connect Firebase'),
);
expect(button.onPressed, isNotNull);
},
);
testWidgets(
'shows the Google button when googleAvailable, and connecting '
'succeeds',