diff --git a/packages/firebase_ui_auth/lib/src/screens/profile_screen.dart b/packages/firebase_ui_auth/lib/src/screens/profile_screen.dart index 86720783..049ae417 100644 --- a/packages/firebase_ui_auth/lib/src/screens/profile_screen.dart +++ b/packages/firebase_ui_auth/lib/src/screens/profile_screen.dart @@ -224,18 +224,24 @@ class _LinkedProvidersRowState extends State<_LinkedProvidersRow> { await user.unlink(providerId); await user.reload(); + if (!mounted) return; + setState(() { widget.onProviderUnlinked(); isEditing = false; }); } on fba.FirebaseAuthException catch (e) { - setState(() { - error = e; - }); + if (mounted) { + setState(() { + error = e; + }); + } } finally { - setState(() { - unlinkingProvider = null; - }); + if (mounted) { + setState(() { + unlinkingProvider = null; + }); + } } } @@ -564,6 +570,8 @@ class _MFAToggleState extends State<_MFAToggle> { final mfa = widget.auth.currentUser!.multiFactor; final factors = await mfa.getEnrolledFactors(); + if (!mounted) return; + if (factors.isEmpty) { setState(() { isLoading = false; @@ -573,23 +581,29 @@ class _MFAToggleState extends State<_MFAToggle> { try { await mfa.unenroll(multiFactorInfo: factors.first); + if (!mounted) return; widget.onToggled?.call(); } on PlatformException catch (e) { if (e.code == 'FirebaseAuthRecentLoginRequiredException') { - if (await _reauthenticate()) { + if (!mounted) return; + if (await _reauthenticate() && mounted) { await _disable(); } } else { rethrow; } } on Exception catch (e) { - setState(() { - exception = e; - }); + if (mounted) { + setState(() { + exception = e; + }); + } } finally { - setState(() { - isLoading = false; - }); + if (mounted) { + setState(() { + isLoading = false; + }); + } } } @@ -618,17 +632,24 @@ class _MFAToggleState extends State<_MFAToggle> { try { await mfa.enroll(assertion); + if (!mounted) return; widget.onToggled?.call(); } on Exception catch (e) { - setState(() { - exception = e; - }); + if (mounted) { + setState(() { + exception = e; + }); + } } finally { - setState(() { - isLoading = false; - }); + if (mounted) { + setState(() { + isLoading = false; + }); + } - if (context.mounted) { + // If currentRoute was removed, popUntil would never find it and + // would pop every route. + if (context.mounted && (currentRoute?.isActive ?? false)) { Navigator.of(context).popUntil((route) => route == currentRoute); } } @@ -636,6 +657,8 @@ class _MFAToggleState extends State<_MFAToggle> { ], ); + if (!mounted) return; + setState(() { isLoading = false; }); diff --git a/packages/firebase_ui_auth/lib/src/widgets/delete_account_button.dart b/packages/firebase_ui_auth/lib/src/widgets/delete_account_button.dart index 9634404b..3121d488 100644 --- a/packages/firebase_ui_auth/lib/src/widgets/delete_account_button.dart +++ b/packages/firebase_ui_auth/lib/src/widgets/delete_account_button.dart @@ -116,7 +116,7 @@ class _DeleteAccountButtonState extends State { } } - if (!(confirmed ?? false)) return; + if (!(confirmed ?? false) || !mounted) return; setState(() { _isLoading = true; @@ -134,6 +134,7 @@ class _DeleteAccountButtonState extends State { await FirebaseUIAuth.signOut(context: context, auth: auth); } on fba.FirebaseAuthException catch (err) { if (err.code == 'requires-recent-login') { + if (!mounted) return; if (widget.onSignInRequired != null) { final signedIn = await widget.onSignInRequired!(); if (signedIn) { @@ -144,9 +145,11 @@ class _DeleteAccountButtonState extends State { } on Exception catch (e) { widget.onDeleteFailed?.call(e); } finally { - setState(() { - _isLoading = false; - }); + if (mounted) { + setState(() { + _isLoading = false; + }); + } } } diff --git a/packages/firebase_ui_auth/test/screens/profile_screen_test.dart b/packages/firebase_ui_auth/test/screens/profile_screen_test.dart new file mode 100644 index 00000000..496e2812 --- /dev/null +++ b/packages/firebase_ui_auth/test/screens/profile_screen_test.dart @@ -0,0 +1,437 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:firebase_auth/firebase_auth.dart' as fba; +import 'package:firebase_ui_auth/firebase_ui_auth.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../test_utils.dart'; + +class _FakeUserInfo extends Fake implements fba.UserInfo { + _FakeUserInfo(this.providerId); + + @override + final String providerId; +} + +class _FakeMultiFactorInfo extends Fake implements fba.MultiFactorInfo {} + +class _FakeMultiFactor extends Fake implements fba.MultiFactor { + Future> Function() enrolledFactors = () async => [ + _FakeMultiFactorInfo(), + ]; + final unenrollCompleter = Completer(); + int unenrollCalls = 0; + final enrollCompleter = Completer(); + + @override + Future> getEnrolledFactors() => enrolledFactors(); + + @override + Future unenroll({ + String? factorUid, + fba.MultiFactorInfo? multiFactorInfo, + }) { + unenrollCalls++; + return unenrollCompleter.future; + } + + @override + Future getSession() async => + _FakeMultiFactorSession(); + + @override + Future enroll( + fba.MultiFactorAssertion assertion, { + String? displayName, + }) => enrollCompleter.future; +} + +class _FakeMultiFactorSession extends Fake implements fba.MultiFactorSession {} + +class _FakeUser extends Fake implements fba.User { + final unlinkCompleter = Completer(); + final deleteCompleter = Completer(); + + @override + final _FakeMultiFactor multiFactor = _FakeMultiFactor(); + + @override + bool get emailVerified => true; + + @override + String? get displayName => 'Test User'; + + @override + String? get photoURL => null; + + @override + List get providerData => [ + _FakeUserInfo('password'), + _FakeUserInfo('phone'), + ]; + + @override + Future unlink(String providerId) => unlinkCompleter.future; + + @override + Future reload() async {} + + @override + Future delete() => deleteCompleter.future; +} + +class _FakeAuth extends Fake implements fba.FirebaseAuth { + _FakeAuth(this.currentUser); + + @override + final fba.User? currentUser; + + @override + final app = MockApp(); +} + +void main() { + group('$ProfileScreen', () { + setUpAll(() { + setFirebaseUiIsTestMode(true); + }); + + Future pumpProfileScreen( + WidgetTester tester, + fba.FirebaseAuth auth, { + bool showMFATile = false, + List? providers, + }) async { + await tester.pumpWidget( + MaterialApp( + home: ProfileScreen( + auth: auth, + providers: providers ?? [EmailAuthProvider(), PhoneAuthProvider()], + showMFATile: showMFATile, + ), + ), + ); + await tester.pumpAndSettle(); + } + + Future removeProfileScreen(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox()); + expect(find.byType(ProfileScreen), findsNothing); + } + + group('unlinking a provider', () { + Future startUnlinking(WidgetTester tester) async { + final linkedProviders = find + .ancestor( + of: find.text('Sign-in methods'), + matching: find.byType(Column), + ) + .first; + await tester.tap( + find.descendant( + of: linkedProviders, + matching: find.byIcon(Icons.edit), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.remove_circle).first); + await tester.pump(); + } + + testWidgets("doesn't call setState if disposed before it succeeds", ( + tester, + ) async { + final user = _FakeUser(); + await pumpProfileScreen(tester, _FakeAuth(user)); + await startUnlinking(tester); + + await removeProfileScreen(tester); + + user.unlinkCompleter.complete(user); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); + + testWidgets("doesn't call setState if disposed before it fails", ( + tester, + ) async { + final user = _FakeUser(); + await pumpProfileScreen(tester, _FakeAuth(user)); + await startUnlinking(tester); + + await removeProfileScreen(tester); + + user.unlinkCompleter.completeError( + fba.FirebaseAuthException(code: 'no-such-provider'), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); + }); + + testWidgets( + "doesn't show the re-authentication dialog if disposed before deleting " + 'the account fails with a recent login error', + (tester) async { + final user = _FakeUser(); + await pumpProfileScreen(tester, _FakeAuth(user)); + await tester.ensureVisible(find.byType(DeleteAccountButton)); + await tester.tap(find.byType(DeleteAccountButton)); + await tester.pump(); + + await removeProfileScreen(tester); + + user.deleteCompleter.completeError( + fba.FirebaseAuthException(code: 'requires-recent-login'), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.byType(ReauthenticateDialog), findsNothing); + }, + ); + + group('disabling MFA', () { + testWidgets( + "doesn't call setState or unenroll if disposed while loading the " + 'enrolled factors', + (tester) async { + final user = _FakeUser(); + await pumpProfileScreen(tester, _FakeAuth(user), showMFATile: true); + + final factors = Completer>(); + user.multiFactor.enrolledFactors = () => factors.future; + await tester.tap(find.text('Disable')); + await tester.pump(); + + await removeProfileScreen(tester); + + factors.complete([_FakeMultiFactorInfo()]); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(user.multiFactor.unenrollCalls, 0); + }, + ); + + testWidgets( + "doesn't call setState or onToggled if disposed before unenrolling " + 'succeeds', + (tester) async { + final user = _FakeUser(); + await pumpProfileScreen(tester, _FakeAuth(user), showMFATile: true); + await tester.tap(find.text('Disable')); + await tester.pump(); + + await removeProfileScreen(tester); + + user.multiFactor.unenrollCompleter.complete(); + await tester.pump(); + + expect(tester.takeException(), isNull); + }, + ); + + testWidgets("doesn't call setState or re-authenticate if disposed before " + 'unenrolling fails with a recent login error', (tester) async { + final user = _FakeUser(); + await pumpProfileScreen(tester, _FakeAuth(user), showMFATile: true); + await tester.tap(find.text('Disable')); + await tester.pump(); + + await removeProfileScreen(tester); + + user.multiFactor.unenrollCompleter.completeError( + PlatformException(code: 'FirebaseAuthRecentLoginRequiredException'), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); + + testWidgets( + "doesn't call setState if disposed before unenrolling fails", + (tester) async { + final user = _FakeUser(); + await pumpProfileScreen(tester, _FakeAuth(user), showMFATile: true); + await tester.tap(find.text('Disable')); + await tester.pump(); + + await removeProfileScreen(tester); + + user.multiFactor.unenrollCompleter.completeError( + fba.FirebaseAuthException(code: 'network-request-failed'), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + "doesn't call setState or retry if disposed while re-authenticating", + (tester) async { + final user = _FakeUser(); + final auth = _FakeAuth(user); + // The email form in the re-authentication dialog needs a real + // Firebase app, so only offer phone re-authentication. + final providers = [PhoneAuthProvider()]; + await pumpProfileScreen( + tester, + auth, + showMFATile: true, + providers: providers, + ); + await tester.tap(find.text('Disable')); + await tester.pump(); + + user.multiFactor.unenrollCompleter.completeError( + PlatformException(code: 'FirebaseAuthRecentLoginRequiredException'), + ); + // The toggle's loading indicator never settles, so pump through + // the dialog's entrance transition instead. + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(find.byType(ReauthenticateDialog), findsOneWidget); + + // Rebuilding without the MFA tile disposes it under the dialog. + await pumpProfileScreen(tester, auth, providers: providers); + + Navigator.of( + tester.element(find.byType(ReauthenticateDialog)), + ).pop(true); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(user.multiFactor.unenrollCalls, 1); + }, + ); + }); + + group('enabling MFA', () { + Future startEnabling( + WidgetTester tester, + fba.FirebaseAuth auth, + ) async { + await pumpProfileScreen(tester, auth, showMFATile: true); + await tester.tap(find.text('Enable')); + await tester.pumpAndSettle(); + expect(find.byType(PhoneInputScreen), findsOneWidget); + + // Rebuilding without the MFA tile disposes it under the phone + // verification screen. + await pumpProfileScreen(tester, auth); + } + + Future receiveCredential(WidgetTester tester) async { + final context = tester.element(find.byType(PhoneInputScreen)); + FirebaseUIAction.ofType>( + context, + )!.invoke( + context, + CredentialReceived( + fba.PhoneAuthProvider.credential( + verificationId: 'verification-id', + smsCode: '123456', + ), + ), + ); + await tester.pump(); + } + + testWidgets( + "doesn't call setState if disposed before phone verification is " + 'closed', + (tester) async { + final user = _FakeUser(); + user.multiFactor.enrolledFactors = () async => []; + await startEnabling(tester, _FakeAuth(user)); + + Navigator.of(tester.element(find.byType(PhoneInputScreen))).pop(); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + "doesn't call setState or onToggled if disposed before enrolling " + 'completes', + (tester) async { + final user = _FakeUser(); + user.multiFactor.enrolledFactors = () async => []; + await startEnabling(tester, _FakeAuth(user)); + + await receiveCredential(tester); + + user.multiFactor.enrollCompleter.complete(); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(PhoneInputScreen), findsNothing); + }, + ); + + testWidgets( + "doesn't pop past the profile screen's route once it's been removed", + (tester) async { + final user = _FakeUser(); + user.multiFactor.enrolledFactors = () async => []; + final auth = _FakeAuth(user); + final navigatorKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp(navigatorKey: navigatorKey, home: const Text('Home')), + ); + final profileRoute = MaterialPageRoute( + builder: (_) => ProfileScreen( + auth: auth, + providers: [EmailAuthProvider(), PhoneAuthProvider()], + showMFATile: true, + ), + ); + navigatorKey.currentState!.push(profileRoute); + await tester.pumpAndSettle(); + await tester.tap(find.text('Enable')); + await tester.pumpAndSettle(); + expect(find.byType(PhoneInputScreen), findsOneWidget); + + navigatorKey.currentState!.removeRoute(profileRoute); + await tester.pump(); + + await receiveCredential(tester); + user.multiFactor.enrollCompleter.complete(); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Home', skipOffstage: false), findsOneWidget); + }, + ); + + testWidgets("doesn't call setState if disposed before enrolling fails", ( + tester, + ) async { + final user = _FakeUser(); + user.multiFactor.enrolledFactors = () async => []; + await startEnabling(tester, _FakeAuth(user)); + await receiveCredential(tester); + + user.multiFactor.enrollCompleter.completeError( + fba.FirebaseAuthException(code: 'invalid-verification-code'), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(PhoneInputScreen), findsNothing); + }); + }); + }); +} diff --git a/packages/firebase_ui_auth/test/widgets/delete_account_button_test.dart b/packages/firebase_ui_auth/test/widgets/delete_account_button_test.dart new file mode 100644 index 00000000..0aa033aa --- /dev/null +++ b/packages/firebase_ui_auth/test/widgets/delete_account_button_test.dart @@ -0,0 +1,156 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:firebase_auth/firebase_auth.dart' as fba; +import 'package:firebase_ui_auth/firebase_ui_auth.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../test_utils.dart'; + +class _FakeUser extends Fake implements fba.User { + var deleteCompleter = Completer(); + int deleteCalls = 0; + + @override + Future delete() { + deleteCalls++; + return deleteCompleter.future; + } +} + +class _FakeAuth extends Fake implements fba.FirebaseAuth { + _FakeAuth(this.currentUser); + + @override + final fba.User? currentUser; +} + +void main() { + group('$DeleteAccountButton', () { + Future removeButton(WidgetTester tester) async { + await tester.pumpWidget(const TestMaterialApp(child: SizedBox())); + expect(find.byType(DeleteAccountButton), findsNothing); + } + + testWidgets( + "doesn't call setState if disposed while the account is being deleted", + (tester) async { + final user = _FakeUser(); + final auth = _FakeAuth(user); + + await tester.pumpWidget( + TestMaterialApp(child: DeleteAccountButton(auth: auth)), + ); + await tester.tap(find.byType(DeleteAccountButton)); + await tester.pump(); + + await removeButton(tester); + + user.deleteCompleter.complete(); + await tester.pump(); + + expect(tester.takeException(), isNull); + }, + ); + + testWidgets("doesn't call setState or delete the account if disposed while " + 'waiting for confirmation', (tester) async { + final user = _FakeUser(); + final auth = _FakeAuth(user); + final confirmation = Completer(); + + await tester.pumpWidget( + TestMaterialApp( + child: DeleteAccountButton( + auth: auth, + showDeleteConfirmationDialog: true, + deleteConfirmation: (_) => confirmation.future, + ), + ), + ); + await tester.tap(find.byType(DeleteAccountButton)); + await tester.pump(); + + await removeButton(tester); + + confirmation.complete(true); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(user.deleteCalls, 0); + }); + + testWidgets( + "doesn't ask to re-authenticate if disposed before deleting fails with " + 'a recent login error', + (tester) async { + final user = _FakeUser(); + final auth = _FakeAuth(user); + var signInRequiredCalls = 0; + + await tester.pumpWidget( + TestMaterialApp( + child: DeleteAccountButton( + auth: auth, + onSignInRequired: () async { + signInRequiredCalls++; + return true; + }, + ), + ), + ); + await tester.tap(find.byType(DeleteAccountButton)); + await tester.pump(); + + await removeButton(tester); + + user.deleteCompleter.completeError( + fba.FirebaseAuthException(code: 'requires-recent-login'), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(signInRequiredCalls, 0); + expect(user.deleteCalls, 1); + }, + ); + + testWidgets( + "doesn't call setState or retry if disposed while re-authenticating", + (tester) async { + final user = _FakeUser(); + final auth = _FakeAuth(user); + final signedIn = Completer(); + + await tester.pumpWidget( + TestMaterialApp( + child: DeleteAccountButton( + auth: auth, + onSignInRequired: () => signedIn.future, + ), + ), + ); + await tester.tap(find.byType(DeleteAccountButton)); + await tester.pump(); + + user.deleteCompleter.completeError( + fba.FirebaseAuthException(code: 'requires-recent-login'), + ); + await tester.pump(); + + await removeButton(tester); + + user.deleteCompleter = Completer(); + signedIn.complete(true); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(user.deleteCalls, 1); + }, + ); + }); +} diff --git a/packages/firebase_ui_storage/lib/src/widgets/upload_button.dart b/packages/firebase_ui_storage/lib/src/widgets/upload_button.dart index eabd3b87..b9fa4705 100644 --- a/packages/firebase_ui_storage/lib/src/widgets/upload_button.dart +++ b/packages/firebase_ui_storage/lib/src/widgets/upload_button.dart @@ -96,9 +96,11 @@ class _UploadButtonState extends State { } catch (e, stackTrace) { widget.onError(e, stackTrace); } finally { - setState(() { - isLoading = false; - }); + if (mounted) { + setState(() { + isLoading = false; + }); + } } } diff --git a/packages/firebase_ui_storage/test/upload_button_test.dart b/packages/firebase_ui_storage/test/upload_button_test.dart new file mode 100644 index 00000000..5b59f8a7 --- /dev/null +++ b/packages/firebase_ui_storage/test/upload_button_test.dart @@ -0,0 +1,103 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +import 'package:firebase_storage/firebase_storage.dart'; +import 'package:firebase_ui_storage/firebase_ui_storage.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeTaskSnapshot extends Fake implements TaskSnapshot {} + +class _FakeUploadTask extends Fake implements UploadTask { + final _completer = Completer(); + + void complete() => _completer.complete(_FakeTaskSnapshot()); + + @override + Future then( + FutureOr Function(TaskSnapshot value) onValue, { + Function? onError, + }) => _completer.future.then(onValue, onError: onError); +} + +class _FakeReference extends Fake implements Reference { + _FakeReference(this.task); + + final UploadTask task; + + @override + Reference child(String path) => this; + + @override + UploadTask putFile(File file, [SettableMetadata? metadata]) => task; +} + +class _FakeStorage extends Fake implements FirebaseStorage {} + +void main() { + group('$UploadButton', () { + const fileSelectorChannel = MethodChannel( + 'plugins.flutter.io/file_selector', + ); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(fileSelectorChannel, (call) async { + if (call.method == 'openFile') return ['/path/to/file.txt']; + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(fileSelectorChannel, null); + }); + + testWidgets( + "doesn't call setState if disposed while the upload is in progress", + (tester) async { + final storage = _FakeStorage(); + final task = _FakeUploadTask(); + Reference? uploaded; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FirebaseUIStorageConfigOverride( + config: FirebaseUIStorageConfiguration( + storage: storage, + uploadRoot: _FakeReference(task), + ), + child: UploadButton( + storage: storage, + onError: (error, stackTrace) {}, + onUploadComplete: (ref) => uploaded = ref, + ), + ), + ), + ), + ); + await tester.tap(find.byType(UploadButton)); + await tester.pump(); + + await tester.pumpWidget(const SizedBox()); + expect(find.byType(UploadButton), findsNothing); + + task.complete(); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(uploaded, isNotNull); + }, + // On the web the picked file is read with XFile.readAsBytes, which + // can't be faked through the file_selector method channel. + skip: kIsWeb, + ); + }); +}