From 4ae8f1e962be121151df0c617cebc04b4a06d313 Mon Sep 17 00:00:00 2001 From: Bernard Roche Date: Tue, 8 Sep 2026 08:02:04 +0100 Subject: [PATCH 1/2] feat(ui_oauth_google)!: migrate to google_sign_in 7 Migrates GoogleProvider to the google_sign_in 7 API, which adopts the UIScene lifecycle on iOS (google_sign_in_ios 6.3.0) and Swift Package Manager (6.3.3). Fixes the deprecated application lifecycle warning tracked in #673. - GoogleSignIn is now the shared instance and is initialized once before the first sign-in. - Authentication and authorization are separate steps. The provider reuses an existing authorization for the requested scopes and prompts for consent when one is not available, so the credential still carries an access token when scopes are requested. - A cancelled sign-in surfaces as AuthCancelledException, matching the previous flow reset behaviour. - Adds an optional serverClientId parameter to GoogleProvider, GoogleSignInButton and GoogleSignInIconButton for Android apps that do not use google-services.json. - Updates the integration test mocks to the new API. BREAKING CHANGE: consumers must follow the google_sign_in 7 platform integration steps: GIDClientID in Info.plist on iOS, and on Android a web OAuth client entry in google-services.json or an explicit serverClientId. When no scopes are requested the credential now contains only an ID token. --- .../firebase_ui_auth/example/pubspec.yaml | 2 +- .../lib/firebase_ui_oauth_google.dart | 5 + .../lib/src/provider.dart | 91 ++++++++--- .../firebase_ui_oauth_google/pubspec.yaml | 2 +- .../google_sign_in_test.dart | 150 ++++++++++-------- tests/pubspec.yaml | 2 +- 6 files changed, 162 insertions(+), 90 deletions(-) diff --git a/packages/firebase_ui_auth/example/pubspec.yaml b/packages/firebase_ui_auth/example/pubspec.yaml index 4d354e42..a9f2ce8a 100644 --- a/packages/firebase_ui_auth/example/pubspec.yaml +++ b/packages/firebase_ui_auth/example/pubspec.yaml @@ -49,7 +49,7 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 - google_sign_in: ^6.2.1 + google_sign_in: ^7.1.0 http: ^1.1.2 integration_test: sdk: flutter diff --git a/packages/firebase_ui_oauth_google/lib/firebase_ui_oauth_google.dart b/packages/firebase_ui_oauth_google/lib/firebase_ui_oauth_google.dart index 5d1b3a16..a8c3c55f 100644 --- a/packages/firebase_ui_oauth_google/lib/firebase_ui_oauth_google.dart +++ b/packages/firebase_ui_oauth_google/lib/firebase_ui_oauth_google.dart @@ -16,6 +16,7 @@ class GoogleSignInButton extends _GoogleSignInButton { super.key, required super.loadingIndicator, required super.clientId, + super.serverClientId, super.redirectUri, super.scopes, super.action = null, @@ -36,6 +37,7 @@ class GoogleSignInIconButton extends _GoogleSignInButton { const GoogleSignInIconButton({ super.key, required super.clientId, + super.serverClientId, required super.loadingIndicator, super.scopes, super.action = null, @@ -75,6 +77,7 @@ class _GoogleSignInButton extends StatelessWidget { final SignedInCallback? onSignedIn; final double size; final String clientId; + final String? serverClientId; final String? redirectUri; final List? scopes; final void Function(Exception exception)? onError; @@ -83,6 +86,7 @@ class _GoogleSignInButton extends StatelessWidget { const _GoogleSignInButton({ super.key, required this.clientId, + this.serverClientId, required this.loadingIndicator, this.scopes, String? label, @@ -106,6 +110,7 @@ class _GoogleSignInButton extends StatelessWidget { return GoogleProvider( clientId: clientId, + serverClientId: serverClientId, redirectUri: redirectUri, scopes: scopes ?? [], ); diff --git a/packages/firebase_ui_oauth_google/lib/src/provider.dart b/packages/firebase_ui_oauth_google/lib/src/provider.dart index 48e2f0dc..2b52aca0 100644 --- a/packages/firebase_ui_oauth_google/lib/src/provider.dart +++ b/packages/firebase_ui_oauth_google/lib/src/provider.dart @@ -17,6 +17,14 @@ class GoogleProvider extends OAuthProvider { /// Ignored on Android and iOS (if `iOSPreferPlist` is true). final String clientId; + /// The client ID of the web OAuth client associated with the app's + /// server-side component, if any. + /// + /// On Android this is required to receive an ID token, unless the app uses + /// `google-services.json` and it contains a web OAuth client entry, in + /// which case the plugin reads the value from there. + final String? serverClientId; + /// When true, the Google Sign In plugin will use the GoogleService-Info.plist /// for configuration instead of the `clientId` parameter. final bool iOSPreferPlist; @@ -28,7 +36,14 @@ class GoogleProvider extends OAuthProvider { /// The list of requested authorization scopes requested when signing in. final List? scopes; - late GoogleSignIn provider; + /// The plugin instance. google_sign_in 7 exposes a single shared instance. + /// Assignable so that tests can inject a mock. + GoogleSignIn provider = GoogleSignIn.instance; + + // google_sign_in 7 requires initialize to be called exactly once, while + // multiple GoogleProvider instances may be created (for example one per + // GoogleSignInButton). The first instance to sign in configures the plugin. + static Future? _initialization; @override final fba.GoogleAuthProvider firebaseAuthProvider = fba.GoogleAuthProvider(); @@ -44,6 +59,7 @@ class GoogleProvider extends OAuthProvider { GoogleProvider({ required this.clientId, + this.serverClientId, this.redirectUri, this.scopes, this.iOSPreferPlist = false, @@ -51,12 +67,6 @@ class GoogleProvider extends OAuthProvider { firebaseAuthProvider.setCustomParameters(const { 'prompt': 'select_account', }); - - if (_ignoreClientId()) { - provider = GoogleSignIn(scopes: scopes ?? []); - } else { - provider = GoogleSignIn(clientId: clientId, scopes: scopes ?? []); - } } bool _ignoreClientId() { @@ -68,25 +78,57 @@ class GoogleProvider extends OAuthProvider { return false; } + Future _ensureInitialized() { + final initialization = _initialization ??= provider.initialize( + clientId: _ignoreClientId() ? null : clientId, + serverClientId: serverClientId, + ); + + return initialization.catchError((Object err) { + // Allow a later sign-in attempt to retry initialization instead of + // rethrowing the same stale error forever. + _initialization = null; + throw err; + }); + } + @override void mobileSignIn(AuthAction action) async { - provider - .signIn() - .then((user) { - if (user == null) throw AuthCancelledException(); - return user.authentication; - }) - .then((auth) { - final credential = fba.GoogleAuthProvider.credential( - accessToken: auth.accessToken, - idToken: auth.idToken, - ); - - onCredentialReceived(credential, action); - }) - .catchError((err) { - authListener.onError(err); - }); + final requestedScopes = scopes ?? const []; + + try { + await _ensureInitialized(); + + final account = await provider.authenticate(scopeHint: requestedScopes); + + // Authentication and authorization are separate steps in + // google_sign_in 7. Reuse an existing authorization when one is + // available, otherwise prompt for the requested scopes so that the + // credential carries an access token, matching the previous behavior. + String? accessToken; + if (requestedScopes.isNotEmpty) { + final client = account.authorizationClient; + final authorization = + await client.authorizationForScopes(requestedScopes) ?? + await client.authorizeScopes(requestedScopes); + accessToken = authorization.accessToken; + } + + final credential = fba.GoogleAuthProvider.credential( + accessToken: accessToken, + idToken: account.authentication.idToken, + ); + + onCredentialReceived(credential, action); + } on GoogleSignInException catch (err) { + if (err.code == GoogleSignInExceptionCode.canceled) { + authListener.onError(AuthCancelledException()); + } else { + authListener.onError(err); + } + } catch (err) { + authListener.onError(err); + } } @override @@ -112,6 +154,7 @@ class GoogleProvider extends OAuthProvider { if (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.macOS) { + await _ensureInitialized(); await provider.signOut(); } } diff --git a/packages/firebase_ui_oauth_google/pubspec.yaml b/packages/firebase_ui_oauth_google/pubspec.yaml index 30f292f1..e119086c 100644 --- a/packages/firebase_ui_oauth_google/pubspec.yaml +++ b/packages/firebase_ui_oauth_google/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: firebase_ui_oauth: ^2.1.0 flutter: sdk: flutter - google_sign_in: ^6.2.1 + google_sign_in: ^7.1.0 dev_dependencies: flutter_test: diff --git a/tests/integration_test/firebase_ui_oauth_google/google_sign_in_test.dart b/tests/integration_test/firebase_ui_oauth_google/google_sign_in_test.dart index a6880940..2bbb2ff4 100644 --- a/tests/integration_test/firebase_ui_oauth_google/google_sign_in_test.dart +++ b/tests/integration_test/firebase_ui_oauth_google/google_sign_in_test.dart @@ -14,11 +14,13 @@ import 'package:mockito/mockito.dart'; import '../utils.dart'; +const _scopes = ['scope1', 'scope2']; + void main() async { late GoogleProvider provider = GoogleProvider( clientId: 'clientId', redirectUri: 'redirectUri', - scopes: const ['scope1', 'scope2'], + scopes: _scopes, ); setUp(() { @@ -28,72 +30,70 @@ void main() async { const labels = DefaultLocalizations(); - group( - 'Sign in with Google button', - () { - testWidgets('has a correct button label', (tester) async { - await render(tester, OAuthProviderButton(provider: provider)); - expect(find.text(labels.signInWithGoogleButtonText), findsOneWidget); - }); - - testWidgets('calls sign in when tapped', (tester) async { - await render(tester, OAuthProviderButton(provider: provider)); + group('Sign in with Google button', () { + testWidgets('has a correct button label', (tester) async { + await render(tester, OAuthProviderButton(provider: provider)); + expect(find.text(labels.signInWithGoogleButtonText), findsOneWidget); + }); - final button = find.byType(OAuthProviderButtonBase); - await tester.tap(button); + testWidgets('calls sign in when tapped', (tester) async { + await render(tester, OAuthProviderButton(provider: provider)); - await tester.pumpAndSettle(); - verify(provider.provider.signIn()).called(1); + final button = find.byType(OAuthProviderButtonBase); + await tester.tap(button); - expect(true, isTrue); - }); + await tester.pumpAndSettle(); + verify(provider.provider.authenticate(scopeHint: _scopes)).called(1); - testWidgets('shows loading indicator when sign in is in progress', ( - tester, - ) async { - await render(tester, OAuthProviderButton(provider: provider)); + expect(true, isTrue); + }); - when(provider.provider.signIn()).thenAnswer((realInvocation) async { - await Future.delayed(const Duration(milliseconds: 50)); - return MockGoogleSignInAccount(); - }); + testWidgets('shows loading indicator when sign in is in progress', ( + tester, + ) async { + await render(tester, OAuthProviderButton(provider: provider)); - final button = find.byType(OAuthProviderButtonBase); - await tester.tap(button); - await tester.pump(); - - expect(find.byType(CircularProgressIndicator), findsOneWidget); + when(provider.provider.authenticate(scopeHint: _scopes)).thenAnswer(( + realInvocation, + ) async { + await Future.delayed(const Duration(milliseconds: 50)); + return MockGoogleSignInAccount(); }); - testWidgets('signs the user in', (tester) async { - await render(tester, OAuthProviderButton(provider: provider)); + final button = find.byType(OAuthProviderButtonBase); + await tester.tap(button); + await tester.pump(); - final button = find.byType(OAuthProviderButtonBase); - await tester.tap(button); - await tester.pumpAndSettle(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); - final user = auth.currentUser!; + testWidgets('signs the user in', (tester) async { + await render(tester, OAuthProviderButton(provider: provider)); - expect(user.displayName, 'Test User'); - expect(user.email, 'test@test.com'); - }); + final button = find.byType(OAuthProviderButtonBase); + await tester.tap(button); + await tester.pumpAndSettle(); - testWidgets('works standalone', (tester) async { - await render( - tester, - const GoogleSignInButton( - loadingIndicator: CircularProgressIndicator(), - clientId: 'test', - ), - ); + final user = auth.currentUser!; - final button = find.byType(GoogleSignInButton); - await tester.tap(button); - await tester.pump(); - }); - }, - skip: !provider.supportsPlatform(defaultTargetPlatform), - ); + expect(user.displayName, 'Test User'); + expect(user.email, 'test@test.com'); + }); + + testWidgets('works standalone', (tester) async { + await render( + tester, + const GoogleSignInButton( + loadingIndicator: CircularProgressIndicator(), + clientId: 'test', + ), + ); + + final button = find.byType(GoogleSignInButton); + await tester.tap(button); + await tester.pump(); + }); + }, skip: !provider.supportsPlatform(defaultTargetPlatform)); } // Mock JWT with the following payload: @@ -106,25 +106,49 @@ void main() async { const _jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlRlc3QgVXNlciIsImVtYWlsIjoidGVzdEB0ZXN0LmNvbSIsImlhdCI6MTUxNjIzOTAyMn0.m5qYto_Vs5ELTURC8rkD-JAJuoosdQZeuUZ_qFrEiaE'; -class MockAuthentication extends Mock implements GoogleSignInAuthentication { +class MockAuthorizationClient extends Mock + implements GoogleSignInAuthorizationClient { @override - final String accessToken = _jwt; + Future authorizationForScopes( + List scopes, + ) async { + return const GoogleSignInClientAuthorization(accessToken: _jwt); + } } // ignore: must_be_immutable class MockGoogleSignInAccount extends Mock implements GoogleSignInAccount { @override - Future get authentication async => - MockAuthentication(); + GoogleSignInAuthentication get authentication => + const GoogleSignInAuthentication(idToken: _jwt); + + @override + GoogleSignInAuthorizationClient get authorizationClient => + MockAuthorizationClient(); } class MockGoogleSignIn extends Mock implements GoogleSignIn { @override - Future signIn() async { + Future initialize({ + String? clientId, + String? serverClientId, + String? nonce, + String? hostedDomain, + }) async {} + + @override + Future authenticate({ + List scopeHint = const [], + }) { return super.noSuchMethod( - Invocation.method(#signIn, []), - returnValue: MockGoogleSignInAccount(), - returnValueForMissingStub: MockGoogleSignInAccount(), - ); + Invocation.method(#authenticate, [], {#scopeHint: scopeHint}), + returnValue: Future.value( + MockGoogleSignInAccount(), + ), + returnValueForMissingStub: Future.value( + MockGoogleSignInAccount(), + ), + ) + as Future; } } diff --git a/tests/pubspec.yaml b/tests/pubspec.yaml index 0c1a21fa..958a7aee 100644 --- a/tests/pubspec.yaml +++ b/tests/pubspec.yaml @@ -26,7 +26,7 @@ dependencies: cloud_firestore: ^6.6.0 firebase_ui_firestore: ^2.1.0 http: ^1.1.2 - google_sign_in: ^6.2.1 + google_sign_in: ^7.1.0 firebase_ui_shared: ^1.5.0 firebase_database: ^12.4.4 firebase_ui_database: ^2.1.0 From 160260bc3c901a8d9a61e328c5dda50151f9ce97 Mon Sep 17 00:00:00 2001 From: Bernard Roche Date: Tue, 8 Sep 2026 08:32:56 +0100 Subject: [PATCH 2/2] refactor(ui_oauth_google): simplify init and isolate it in tests Chain the retry-reset catchError directly onto initialize() instead of threading an intermediate local, and add a @visibleForTesting debugReset() that clears the static one-time-init future so each test starts from a clean state with its freshly injected mock. Addresses review feedback on #689. --- .../lib/src/provider.dart | 15 +++++++++++---- .../google_sign_in_test.dart | 1 + 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/firebase_ui_oauth_google/lib/src/provider.dart b/packages/firebase_ui_oauth_google/lib/src/provider.dart index 2b52aca0..69c1a0e1 100644 --- a/packages/firebase_ui_oauth_google/lib/src/provider.dart +++ b/packages/firebase_ui_oauth_google/lib/src/provider.dart @@ -45,6 +45,15 @@ class GoogleProvider extends OAuthProvider { // GoogleSignInButton). The first instance to sign in configures the plugin. static Future? _initialization; + /// Resets the one-time initialization state. + /// + /// The plugin is initialized exactly once per process, so tests that inject + /// a fresh mock in `setUp` must clear the cached future to stay isolated. + @visibleForTesting + static void debugReset() { + _initialization = null; + } + @override final fba.GoogleAuthProvider firebaseAuthProvider = fba.GoogleAuthProvider(); @@ -79,12 +88,10 @@ class GoogleProvider extends OAuthProvider { } Future _ensureInitialized() { - final initialization = _initialization ??= provider.initialize( + return _initialization ??= provider.initialize( clientId: _ignoreClientId() ? null : clientId, serverClientId: serverClientId, - ); - - return initialization.catchError((Object err) { + ).catchError((Object err) { // Allow a later sign-in attempt to retry initialization instead of // rethrowing the same stale error forever. _initialization = null; diff --git a/tests/integration_test/firebase_ui_oauth_google/google_sign_in_test.dart b/tests/integration_test/firebase_ui_oauth_google/google_sign_in_test.dart index 2bbb2ff4..938516dd 100644 --- a/tests/integration_test/firebase_ui_oauth_google/google_sign_in_test.dart +++ b/tests/integration_test/firebase_ui_oauth_google/google_sign_in_test.dart @@ -24,6 +24,7 @@ void main() async { ); setUp(() { + GoogleProvider.debugReset(); provider.provider = MockGoogleSignIn(); setMockGoogleProvider(provider); });