From 26d7507fff52cd005bbd713e0b8608dc03a017b2 Mon Sep 17 00:00:00 2001 From: KRZYSZTOF RUDNICKI Date: Tue, 25 Aug 2026 14:38:00 +0200 Subject: [PATCH] sync_settings_ui: fix Connect Firebase hanging forever on a bad password 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 Claude-Session: https://claude.ai/code/session_01XATAXuMzpYggWyTBPGyiSN --- .../lib/src/firebase_sync_controller.dart | 71 +++++++++++++++++-- .../lib/src/sync_settings_screen.dart | 15 ++-- sync_settings_ui/pubspec.yaml | 2 +- .../test/firebase_sync_controller_test.dart | 64 +++++++++++++++++ .../sync_settings_screen_google_test.dart | 2 +- .../test/sync_settings_screen_test.dart | 42 +++++++++++ 6 files changed, 180 insertions(+), 16 deletions(-) diff --git a/sync_settings_ui/lib/src/firebase_sync_controller.dart b/sync_settings_ui/lib/src/firebase_sync_controller.dart index c870779..48a42ad 100644 --- a/sync_settings_ui/lib/src/firebase_sync_controller.dart +++ b/sync_settings_ui/lib/src/firebase_sync_controller.dart @@ -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 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 diff --git a/sync_settings_ui/lib/src/sync_settings_screen.dart b/sync_settings_ui/lib/src/sync_settings_screen.dart index 4bf6389..9b27a00 100644 --- a/sync_settings_ui/lib/src/sync_settings_screen.dart +++ b/sync_settings_ui/lib/src/sync_settings_screen.dart @@ -58,13 +58,13 @@ class _SyncSettingsScreenState extends State { 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 { _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}', }; }); } diff --git a/sync_settings_ui/pubspec.yaml b/sync_settings_ui/pubspec.yaml index 02b204e..86ba6cd 100644 --- a/sync_settings_ui/pubspec.yaml +++ b/sync_settings_ui/pubspec.yaml @@ -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 diff --git a/sync_settings_ui/test/firebase_sync_controller_test.dart b/sync_settings_ui/test/firebase_sync_controller_test.dart index bc42db2..f95bc13 100644 --- a/sync_settings_ui/test/firebase_sync_controller_test.dart +++ b/sync_settings_ui/test/firebase_sync_controller_test.dart @@ -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 = []; + 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', () { diff --git a/sync_settings_ui/test/sync_settings_screen_google_test.dart b/sync_settings_ui/test/sync_settings_screen_google_test.dart index 1998d45..30ba78d 100644 --- a/sync_settings_ui/test/sync_settings_screen_google_test.dart +++ b/sync_settings_ui/test/sync_settings_screen_google_test.dart @@ -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( diff --git a/sync_settings_ui/test/sync_settings_screen_test.dart b/sync_settings_ui/test/sync_settings_screen_test.dart index 880ef42..f863297 100644 --- a/sync_settings_ui/test/sync_settings_screen_test.dart +++ b/sync_settings_ui/test/sync_settings_screen_test.dart @@ -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( + find.widgetWithText(FilledButton, 'Connect Firebase'), + ); + expect(button.onPressed, isNotNull); + }, + ); + testWidgets( 'shows the Google button when googleAvailable, and connecting ' 'succeeds',