From 43a58b4a24f222024361ba539bc364ae217f8804 Mon Sep 17 00:00:00 2001 From: Stuart Morgan Date: Wed, 10 Sep 2025 15:55:52 -0400 Subject: [PATCH 1/3] [google_sign_in] Implement `disconnect` for Android Adds the missing implementation of `disconnect` using the new `revokeAccess` API, updating `play-services-auth` to the version containing the new API. Fixes https://github.com/flutter/flutter/issues/169612 --- .../google_sign_in_android/CHANGELOG.md | 4 + .../android/build.gradle | 2 +- .../googlesignin/GoogleSignInPlugin.java | 27 ++++- .../flutter/plugins/googlesignin/Messages.kt | 97 ++++++++++++++-- .../plugins/googlesignin/ResultUtils.kt | 4 +- .../googlesignin/GoogleSignInTest.java | 34 ++++++ .../lib/google_sign_in_android.dart | 44 +++++++- .../lib/src/messages.g.dart | 103 +++++++++++++++-- .../pigeons/messages.dart | 23 ++++ .../google_sign_in_android/pubspec.yaml | 2 +- .../test/google_sign_in_android_test.dart | 105 +++++++++++++++++- .../google_sign_in_android_test.mocks.dart | 9 ++ 12 files changed, 422 insertions(+), 32 deletions(-) diff --git a/packages/google_sign_in/google_sign_in_android/CHANGELOG.md b/packages/google_sign_in/google_sign_in_android/CHANGELOG.md index ea013bb543e..80214904d2f 100644 --- a/packages/google_sign_in/google_sign_in_android/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 7.1.0 + +* Adds support for `disconnect`. + ## 7.0.5 * Adds support for `hostedDomain` when authenticating. diff --git a/packages/google_sign_in/google_sign_in_android/android/build.gradle b/packages/google_sign_in/google_sign_in_android/android/build.gradle index 3a12a656595..7ea2f885914 100644 --- a/packages/google_sign_in/google_sign_in_android/android/build.gradle +++ b/packages/google_sign_in/google_sign_in_android/android/build.gradle @@ -70,7 +70,7 @@ dependencies { implementation 'androidx.credentials:credentials:1.5.0' implementation 'androidx.credentials:credentials-play-services-auth:1.5.0' implementation 'com.google.android.libraries.identity.googleid:googleid:1.1.1' - implementation 'com.google.android.gms:play-services-auth:21.3.0' + implementation 'com.google.android.gms:play-services-auth:21.4.0' testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-inline:5.2.0' } diff --git a/packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/GoogleSignInPlugin.java b/packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/GoogleSignInPlugin.java index b9410d737fd..94074066adf 100644 --- a/packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/GoogleSignInPlugin.java +++ b/packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/GoogleSignInPlugin.java @@ -33,6 +33,7 @@ import com.google.android.gms.auth.api.identity.AuthorizationRequest; import com.google.android.gms.auth.api.identity.AuthorizationResult; import com.google.android.gms.auth.api.identity.Identity; +import com.google.android.gms.auth.api.identity.RevokeAccessRequest; import com.google.android.gms.common.api.ApiException; import com.google.android.gms.common.api.Scope; import com.google.android.libraries.identity.googleid.GetGoogleIdOption; @@ -337,12 +338,12 @@ public void clearCredentialState(@NonNull Function1, Unit> new CredentialManagerCallback<>() { @Override public void onResult(Void result) { - ResultUtilsKt.completeWithClearCredentialStateSuccess(callback); + ResultUtilsKt.completeWithUnitSuccess(callback); } @Override public void onError(@NonNull ClearCredentialException e) { - ResultUtilsKt.completeWithClearCredentialStateError( + ResultUtilsKt.completeWithUnitError( callback, new FlutterError("Clear Failed", e.getMessage(), null)); } }); @@ -440,6 +441,28 @@ public void authorize( } } + @Override + public void revokeAccess( + @NonNull PlatformRevokeAccessRequest params, + @NonNull Function1, Unit> callback) { + List scopes = new ArrayList<>(); + for (String scope : params.getScopes()) { + scopes.add(new Scope(scope)); + } + authorizationClientFactory + .create(context) + .revokeAccess( + RevokeAccessRequest.builder() + .setAccount(new Account(params.getAccountEmail(), "com.google")) + .setScopes(scopes) + .build()) + .addOnSuccessListener(unused -> ResultUtilsKt.completeWithUnitSuccess(callback)) + .addOnFailureListener( + e -> + ResultUtilsKt.completeWithUnitError( + callback, new FlutterError("removeAccess failed", e.getMessage(), null))); + } + @Override public boolean onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { if (requestCode == REQUEST_CODE_AUTHORIZE) { diff --git a/packages/google_sign_in/google_sign_in_android/android/src/main/kotlin/io/flutter/plugins/googlesignin/Messages.kt b/packages/google_sign_in/google_sign_in_android/android/src/main/kotlin/io/flutter/plugins/googlesignin/Messages.kt index 91f61b3e15c..ad401548bc4 100644 --- a/packages/google_sign_in/google_sign_in_android/android/src/main/kotlin/io/flutter/plugins/googlesignin/Messages.kt +++ b/packages/google_sign_in/google_sign_in_android/android/src/main/kotlin/io/flutter/plugins/googlesignin/Messages.kt @@ -275,6 +275,53 @@ data class GetCredentialRequestGoogleIdOptionParams( override fun hashCode(): Int = toList().hashCode() } +/** + * Parameters for revoking authorization. + * + * Corresponds to the native RevokeAccessRequest. + * https://developers.google.com/android/reference/com/google/android/gms/auth/api/identity/RevokeAccessRequest + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class PlatformRevokeAccessRequest( + /** The email for the Google account to revoke authorizations for. */ + val accountEmail: String, + /** + * A list of requested scopes. + * + * Per docs, all granted scopes will be revoked, not only the ones passed here. However, at + * least one currently-granted scope must be provided. + */ + val scopes: List +) { + companion object { + fun fromList(pigeonVar_list: List): PlatformRevokeAccessRequest { + val accountEmail = pigeonVar_list[0] as String + val scopes = pigeonVar_list[1] as List + return PlatformRevokeAccessRequest(accountEmail, scopes) + } + } + + fun toList(): List { + return listOf( + accountEmail, + scopes, + ) + } + + override fun equals(other: Any?): Boolean { + if (other !is PlatformRevokeAccessRequest) { + return false + } + if (this === other) { + return true + } + return MessagesPigeonUtils.deepEquals(toList(), other.toList()) + } + + override fun hashCode(): Int = toList().hashCode() +} + /** * Pigeon equivalent of the native GoogleIdTokenCredential. * @@ -525,20 +572,23 @@ private open class MessagesPigeonCodec : StandardMessageCodec() { } } 134.toByte() -> { + return (readValue(buffer) as? List)?.let { PlatformRevokeAccessRequest.fromList(it) } + } + 135.toByte() -> { return (readValue(buffer) as? List)?.let { PlatformGoogleIdTokenCredential.fromList(it) } } - 135.toByte() -> { + 136.toByte() -> { return (readValue(buffer) as? List)?.let { GetCredentialFailure.fromList(it) } } - 136.toByte() -> { + 137.toByte() -> { return (readValue(buffer) as? List)?.let { GetCredentialSuccess.fromList(it) } } - 137.toByte() -> { + 138.toByte() -> { return (readValue(buffer) as? List)?.let { AuthorizeFailure.fromList(it) } } - 138.toByte() -> { + 139.toByte() -> { return (readValue(buffer) as? List)?.let { PlatformAuthorizationResult.fromList(it) } } else -> super.readValueOfType(type, buffer) @@ -567,26 +617,30 @@ private open class MessagesPigeonCodec : StandardMessageCodec() { stream.write(133) writeValue(stream, value.toList()) } - is PlatformGoogleIdTokenCredential -> { + is PlatformRevokeAccessRequest -> { stream.write(134) writeValue(stream, value.toList()) } - is GetCredentialFailure -> { + is PlatformGoogleIdTokenCredential -> { stream.write(135) writeValue(stream, value.toList()) } - is GetCredentialSuccess -> { + is GetCredentialFailure -> { stream.write(136) writeValue(stream, value.toList()) } - is AuthorizeFailure -> { + is GetCredentialSuccess -> { stream.write(137) writeValue(stream, value.toList()) } - is PlatformAuthorizationResult -> { + is AuthorizeFailure -> { stream.write(138) writeValue(stream, value.toList()) } + is PlatformAuthorizationResult -> { + stream.write(139) + writeValue(stream, value.toList()) + } else -> super.writeValue(stream, value) } } @@ -613,6 +667,8 @@ interface GoogleSignInApi { callback: (Result) -> Unit ) + fun revokeAccess(params: PlatformRevokeAccessRequest, callback: (Result) -> Unit) + companion object { /** The codec used by GoogleSignInApi. */ val codec: MessageCodec by lazy { MessagesPigeonCodec() } @@ -717,6 +773,29 @@ interface GoogleSignInApi { channel.setMessageHandler(null) } } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.revokeAccess$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val paramsArg = args[0] as PlatformRevokeAccessRequest + api.revokeAccess(paramsArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(MessagesPigeonUtils.wrapError(error)) + } else { + reply.reply(MessagesPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/packages/google_sign_in/google_sign_in_android/android/src/main/kotlin/io/flutter/plugins/googlesignin/ResultUtils.kt b/packages/google_sign_in/google_sign_in_android/android/src/main/kotlin/io/flutter/plugins/googlesignin/ResultUtils.kt index 4a9f09795f7..820e4ee6c5c 100644 --- a/packages/google_sign_in/google_sign_in_android/android/src/main/kotlin/io/flutter/plugins/googlesignin/ResultUtils.kt +++ b/packages/google_sign_in/google_sign_in_android/android/src/main/kotlin/io/flutter/plugins/googlesignin/ResultUtils.kt @@ -18,11 +18,11 @@ fun completeWithGetCredentialFailure( callback(Result.success(failure)) } -fun completeWithClearCredentialStateSuccess(callback: (Result) -> Unit) { +fun completeWithUnitSuccess(callback: (Result) -> Unit) { callback(Result.success(Unit)) } -fun completeWithClearCredentialStateError(callback: (Result) -> Unit, failure: FlutterError) { +fun completeWithUnitError(callback: (Result) -> Unit, failure: FlutterError) { callback(Result.failure(failure)) } diff --git a/packages/google_sign_in/google_sign_in_android/android/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInTest.java b/packages/google_sign_in/google_sign_in_android/android/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInTest.java index 5b17c765cbb..fafcceaac7a 100644 --- a/packages/google_sign_in/google_sign_in_android/android/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInTest.java +++ b/packages/google_sign_in/google_sign_in_android/android/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInTest.java @@ -40,6 +40,7 @@ import com.google.android.gms.auth.api.identity.AuthorizationClient; import com.google.android.gms.auth.api.identity.AuthorizationRequest; import com.google.android.gms.auth.api.identity.AuthorizationResult; +import com.google.android.gms.auth.api.identity.RevokeAccessRequest; import com.google.android.gms.common.api.ApiException; import com.google.android.gms.common.api.Status; import com.google.android.gms.tasks.OnSuccessListener; @@ -71,6 +72,7 @@ public class GoogleSignInTest { @Mock CustomCredential mockGenericCredential; @Mock GoogleIdTokenCredential mockGoogleCredential; @Mock Task mockAuthorizationTask; + @Mock Task mockRevokeAccessTask; private GoogleSignInPlugin flutterPlugin; // Technically this is not the plugin, but in practice almost all of the functionality is in this @@ -88,6 +90,8 @@ public void setUp() { .thenReturn(GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL); when(mockAuthorizationTask.addOnSuccessListener(any())).thenReturn(mockAuthorizationTask); when(mockAuthorizationTask.addOnFailureListener(any())).thenReturn(mockAuthorizationTask); + when(mockRevokeAccessTask.addOnSuccessListener(any())).thenReturn(mockRevokeAccessTask); + when(mockRevokeAccessTask.addOnFailureListener(any())).thenReturn(mockRevokeAccessTask); when(mockAuthorizationIntent.getIntentSender()).thenReturn(mockAuthorizationIntentSender); when(mockActivityPluginBinding.getActivity()).thenReturn(mockActivity); @@ -1144,4 +1148,34 @@ public void clearCredentialState_reportsFailure() { callbackCaptor.getValue().onError(mock(ClearCredentialException.class)); } + + @Test + public void revokeAccess_callsClient() { + final List scopes = new ArrayList<>(List.of("openid")); + final String accountEmail = "someone@example.com"; + PlatformRevokeAccessRequest params = new PlatformRevokeAccessRequest(accountEmail, scopes); + when(mockAuthorizationClient.revokeAccess(any())).thenReturn(mockRevokeAccessTask); + plugin.revokeAccess( + params, + ResultCompat.asCompatCallback( + reply -> { + return null; + })); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(RevokeAccessRequest.class); + verify(mockAuthorizationClient).revokeAccess(requestCaptor.capture()); + + @SuppressWarnings("unchecked") + ArgumentCaptor> callbackCaptor = + ArgumentCaptor.forClass(OnSuccessListener.class); + verify(mockRevokeAccessTask).addOnSuccessListener(callbackCaptor.capture()); + callbackCaptor.getValue().onSuccess(null); + + RevokeAccessRequest request = requestCaptor.getValue(); + assertEquals(scopes.size(), request.getScopes().size()); + assertEquals(scopes.get(0), request.getScopes().get(0).getScopeUri()); + // Account is mostly opaque, so just verify that one was set. + assertNotNull(request.getAccount()); + } } diff --git a/packages/google_sign_in/google_sign_in_android/lib/google_sign_in_android.dart b/packages/google_sign_in/google_sign_in_android/lib/google_sign_in_android.dart index 15e8d39efdc..fbe10a4c9f9 100644 --- a/packages/google_sign_in/google_sign_in_android/lib/google_sign_in_android.dart +++ b/packages/google_sign_in/google_sign_in_android/lib/google_sign_in_android.dart @@ -21,6 +21,9 @@ class GoogleSignInAndroid extends GoogleSignInPlatform { String? _serverClientId; String? _hostedDomain; String? _nonce; + // A cache of accounts that have been successfully authenticated via this + // plugin instance, and one of the scopes that has been authorized for it. + final Map _cachedAccounts = {}; /// Registers this class as the default instance of [GoogleSignInPlatform]. static void registerWith() { @@ -109,10 +112,26 @@ class GoogleSignInAndroid extends GoogleSignInPlatform { @override Future disconnect(DisconnectParams params) async { - // TODO(stuartmorgan): Implement this once Credential Manager adds the - // necessary API (or temporarily implement it with the deprecated SDK if - // it becomes a significant issue before the API is added). - // https://github.com/flutter/flutter/issues/169612 + // AuthorizationClient requires an account, and at least one currently + // granted scope, to request revocation. The app-facing API currently + // does not take any parameters, and is documented to revoke all authorized + // accounts, so disconnect every account that has been authorized. + // TODO(stuartmorgan): Consider deprecating the account-less API at the + // app-facing level, and have it instead be an account-level method, to + // better align with the current SDKs. + for (final MapEntry entry in _cachedAccounts.entries) { + // Because revokeAccess removes all authorizations for the app, not just + // the scopes provided, (per + // https://developer.android.com/identity/authorization#revoke-permissions) + // an arbitrary granted scope is used here. + await _hostApi.revokeAccess( + PlatformRevokeAccessRequest( + accountEmail: entry.key, + scopes: [entry.value], + ), + ); + } + _cachedAccounts.clear(); await signOut(const SignOutParams()); } @@ -210,6 +229,10 @@ class GoogleSignInAndroid extends GoogleSignInPlatform { details: authnResult.details, ); case GetCredentialSuccess(): + // Store a preliminary entry using the 'openid' scope, which in practice + // always seems to be granted at authentication time, so that an account + // that is authenticated but never authorized can still be disconnected. + _cachedAccounts[authnResult.credential.id] = 'openid'; return authnResult.credential; } } @@ -218,10 +241,11 @@ class GoogleSignInAndroid extends GoogleSignInPlatform { AuthorizationRequestDetails request, { required bool requestOfflineAccess, }) async { + final String? email = request.email; final AuthorizeResult result = await _hostApi.authorize( PlatformAuthorizationRequest( scopes: request.scopes, - accountEmail: request.email, + accountEmail: email, hostedDomain: _hostedDomain, serverClientIdForForcedRefreshToken: requestOfflineAccess ? _serverClientId : null, @@ -258,6 +282,16 @@ class GoogleSignInAndroid extends GoogleSignInPlatform { if (accessToken == null) { return (accessToken: null, serverAuthCode: null); } + // Update the account entry with a scope that was reported as granted, + // just in case for some reason 'openid' isn't valid. If the request + // wasn't associated with an account, then it won't be available to + // disconnect. + // TODO(stuartmorgan): If this becomes an issue, see if there is an + // indirect way to get the associated email address that's not + // deprecated. + if (email != null) { + _cachedAccounts[email] = result.grantedScopes.first; + } return ( accessToken: accessToken, serverAuthCode: result.serverAuthCode, diff --git a/packages/google_sign_in/google_sign_in_android/lib/src/messages.g.dart b/packages/google_sign_in/google_sign_in_android/lib/src/messages.g.dart index dd84e296778..7f84ebbc17a 100644 --- a/packages/google_sign_in/google_sign_in_android/lib/src/messages.g.dart +++ b/packages/google_sign_in/google_sign_in_android/lib/src/messages.g.dart @@ -271,6 +271,59 @@ class GetCredentialRequestGoogleIdOptionParams { int get hashCode => Object.hashAll(_toList()); } +/// Parameters for revoking authorization. +/// +/// Corresponds to the native RevokeAccessRequest. +/// https://developers.google.com/android/reference/com/google/android/gms/auth/api/identity/RevokeAccessRequest +class PlatformRevokeAccessRequest { + PlatformRevokeAccessRequest({ + required this.accountEmail, + required this.scopes, + }); + + /// The email for the Google account to revoke authorizations for. + String accountEmail; + + /// A list of requested scopes. + /// + /// Per docs, all granted scopes will be revoked, not only the ones passed + /// here. However, at least one currently-granted scope must be provided. + List scopes; + + List _toList() { + return [accountEmail, scopes]; + } + + Object encode() { + return _toList(); + } + + static PlatformRevokeAccessRequest decode(Object result) { + result as List; + return PlatformRevokeAccessRequest( + accountEmail: result[0]! as String, + scopes: (result[1] as List?)!.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! PlatformRevokeAccessRequest || + other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + /// Pigeon equivalent of the native GoogleIdTokenCredential. class PlatformGoogleIdTokenCredential { PlatformGoogleIdTokenCredential({ @@ -555,21 +608,24 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is GetCredentialRequestGoogleIdOptionParams) { buffer.putUint8(133); writeValue(buffer, value.encode()); - } else if (value is PlatformGoogleIdTokenCredential) { + } else if (value is PlatformRevokeAccessRequest) { buffer.putUint8(134); writeValue(buffer, value.encode()); - } else if (value is GetCredentialFailure) { + } else if (value is PlatformGoogleIdTokenCredential) { buffer.putUint8(135); writeValue(buffer, value.encode()); - } else if (value is GetCredentialSuccess) { + } else if (value is GetCredentialFailure) { buffer.putUint8(136); writeValue(buffer, value.encode()); - } else if (value is AuthorizeFailure) { + } else if (value is GetCredentialSuccess) { buffer.putUint8(137); writeValue(buffer, value.encode()); - } else if (value is PlatformAuthorizationResult) { + } else if (value is AuthorizeFailure) { buffer.putUint8(138); writeValue(buffer, value.encode()); + } else if (value is PlatformAuthorizationResult) { + buffer.putUint8(139); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -593,14 +649,16 @@ class _PigeonCodec extends StandardMessageCodec { readValue(buffer)!, ); case 134: - return PlatformGoogleIdTokenCredential.decode(readValue(buffer)!); + return PlatformRevokeAccessRequest.decode(readValue(buffer)!); case 135: - return GetCredentialFailure.decode(readValue(buffer)!); + return PlatformGoogleIdTokenCredential.decode(readValue(buffer)!); case 136: - return GetCredentialSuccess.decode(readValue(buffer)!); + return GetCredentialFailure.decode(readValue(buffer)!); case 137: - return AuthorizeFailure.decode(readValue(buffer)!); + return GetCredentialSuccess.decode(readValue(buffer)!); case 138: + return AuthorizeFailure.decode(readValue(buffer)!); + case 139: return PlatformAuthorizationResult.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -748,4 +806,31 @@ class GoogleSignInApi { return (pigeonVar_replyList[0] as AuthorizeResult?)!; } } + + Future revokeAccess(PlatformRevokeAccessRequest params) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.revokeAccess$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [params], + ); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } } diff --git a/packages/google_sign_in/google_sign_in_android/pigeons/messages.dart b/packages/google_sign_in/google_sign_in_android/pigeons/messages.dart index dc9267020ab..12db4ab544e 100644 --- a/packages/google_sign_in/google_sign_in_android/pigeons/messages.dart +++ b/packages/google_sign_in/google_sign_in_android/pigeons/messages.dart @@ -66,6 +66,26 @@ class GetCredentialRequestGoogleIdOptionParams { bool autoSelectEnabled; } +/// Parameters for revoking authorization. +/// +/// Corresponds to the native RevokeAccessRequest. +/// https://developers.google.com/android/reference/com/google/android/gms/auth/api/identity/RevokeAccessRequest +class PlatformRevokeAccessRequest { + PlatformRevokeAccessRequest({ + required this.accountEmail, + required this.scopes, + }); + + /// The email for the Google account to revoke authorizations for. + String accountEmail; + + /// A list of requested scopes. + /// + /// Per docs, all granted scopes will be revoked, not only the ones passed + /// here. However, at least one currently-granted scope must be provided. + List scopes; +} + /// Pigeon equivalent of the native GoogleIdTokenCredential. class PlatformGoogleIdTokenCredential { String? displayName; @@ -202,4 +222,7 @@ abstract class GoogleSignInApi { PlatformAuthorizationRequest params, { required bool promptIfUnauthorized, }); + + @async + void revokeAccess(PlatformRevokeAccessRequest params); } diff --git a/packages/google_sign_in/google_sign_in_android/pubspec.yaml b/packages/google_sign_in/google_sign_in_android/pubspec.yaml index 7d24cfcc335..f056cbec766 100644 --- a/packages/google_sign_in/google_sign_in_android/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_android/pubspec.yaml @@ -2,7 +2,7 @@ name: google_sign_in_android description: Android implementation of the google_sign_in plugin. repository: https://github.com/flutter/packages/tree/main/packages/google_sign_in/google_sign_in_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22 -version: 7.0.5 +version: 7.1.0 environment: sdk: ^3.7.0 diff --git a/packages/google_sign_in/google_sign_in_android/test/google_sign_in_android_test.dart b/packages/google_sign_in/google_sign_in_android/test/google_sign_in_android_test.dart index 50ea9b48abe..894999f9708 100644 --- a/packages/google_sign_in/google_sign_in_android/test/google_sign_in_android_test.dart +++ b/packages/google_sign_in/google_sign_in_android/test/google_sign_in_android_test.dart @@ -959,10 +959,109 @@ void main() { verify(mockApi.clearCredentialState()); }); - test('disconnect also signs out', () async { - await googleSignIn.disconnect(const DisconnectParams()); + group('disconnect', () { + test('calls through with previously authorized accounts', () async { + // Populate the cache of users. + const String userEmail = 'user@example.com'; + const String aScope = 'grantedScope'; + when(mockApi.authorize(any, promptIfUnauthorized: false)).thenAnswer( + (_) async => PlatformAuthorizationResult( + grantedScopes: [aScope], + accessToken: 'token', + ), + ); + await googleSignIn.init(const InitParameters(serverClientId: 'id')); + await googleSignIn.clientAuthorizationTokensForScopes( + const ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: [aScope], + userId: null, + email: userEmail, + promptIfUnauthorized: false, + ), + ), + ); - verify(mockApi.clearCredentialState()); + await googleSignIn.disconnect(const DisconnectParams()); + + final VerificationResult verification = verify( + mockApi.revokeAccess(captureAny), + ); + final PlatformRevokeAccessRequest hostParams = + verification.captured[0] as PlatformRevokeAccessRequest; + expect(hostParams.accountEmail, userEmail); + expect(hostParams.scopes.first, aScope); + }); + + test( + 'calls through with non-authorized accounts, using "openid"', + () async { + // Populate the cache of users. + when(mockApi.getCredential(any)).thenAnswer( + (_) async => GetCredentialSuccess( + credential: PlatformGoogleIdTokenCredential( + displayName: _testUser.displayName, + profilePictureUri: _testUser.photoUrl, + id: _testUser.email, + idToken: _testAuthnToken.idToken!, + ), + ), + ); + await googleSignIn.init(const InitParameters(serverClientId: 'id')); + await googleSignIn.authenticate(const AuthenticateParameters()); + + await googleSignIn.disconnect(const DisconnectParams()); + + final VerificationResult verification = verify( + mockApi.revokeAccess(captureAny), + ); + final PlatformRevokeAccessRequest hostParams = + verification.captured[0] as PlatformRevokeAccessRequest; + expect(hostParams.accountEmail, _testUser.email); + expect(hostParams.scopes.first, 'openid'); + }, + ); + + test('does not re-revoke for repeated disconnect', () async { + // Populate the cache of users. + const String userEmail = 'user@example.com'; + const String aScope = 'grantedScope'; + when(mockApi.authorize(any, promptIfUnauthorized: false)).thenAnswer( + (_) async => PlatformAuthorizationResult( + grantedScopes: [aScope], + accessToken: 'token', + ), + ); + await googleSignIn.init(const InitParameters(serverClientId: 'id')); + await googleSignIn.clientAuthorizationTokensForScopes( + const ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: [aScope], + userId: null, + email: userEmail, + promptIfUnauthorized: false, + ), + ), + ); + + await googleSignIn.disconnect(const DisconnectParams()); + + verify(mockApi.revokeAccess(any)); + + reset(mockApi); + + // Since no accounts have authorized since the last disconnect, this + // should not attempt to revoke anything. + await googleSignIn.disconnect(const DisconnectParams()); + + verifyNever(mockApi.revokeAccess(any)); + }); + + test('also signs out', () async { + await googleSignIn.disconnect(const DisconnectParams()); + + verify(mockApi.clearCredentialState()); + }); }); // Returning null triggers the app-facing package to create stream events, diff --git a/packages/google_sign_in/google_sign_in_android/test/google_sign_in_android_test.mocks.dart b/packages/google_sign_in/google_sign_in_android/test/google_sign_in_android_test.mocks.dart index f3327d75516..b946f9da0e1 100644 --- a/packages/google_sign_in/google_sign_in_android/test/google_sign_in_android_test.mocks.dart +++ b/packages/google_sign_in/google_sign_in_android/test/google_sign_in_android_test.mocks.dart @@ -115,4 +115,13 @@ class MockGoogleSignInApi extends _i1.Mock implements _i2.GoogleSignInApi { ), ) as _i4.Future<_i2.AuthorizeResult>); + + @override + _i4.Future revokeAccess(_i2.PlatformRevokeAccessRequest? params) => + (super.noSuchMethod( + Invocation.method(#revokeAccess, [params]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); } From f36957be79dce950c3cf204740b379ef38a5ee43 Mon Sep 17 00:00:00 2001 From: Stuart Morgan Date: Wed, 10 Sep 2025 16:41:49 -0400 Subject: [PATCH 2/3] Gemini review --- .../io/flutter/plugins/googlesignin/GoogleSignInPlugin.java | 2 +- .../google_sign_in_android/lib/google_sign_in_android.dart | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/GoogleSignInPlugin.java b/packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/GoogleSignInPlugin.java index 94074066adf..554677cd149 100644 --- a/packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/GoogleSignInPlugin.java +++ b/packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/GoogleSignInPlugin.java @@ -460,7 +460,7 @@ public void revokeAccess( .addOnFailureListener( e -> ResultUtilsKt.completeWithUnitError( - callback, new FlutterError("removeAccess failed", e.getMessage(), null))); + callback, new FlutterError("revokeAccess failed", e.getMessage(), null))); } @Override diff --git a/packages/google_sign_in/google_sign_in_android/lib/google_sign_in_android.dart b/packages/google_sign_in/google_sign_in_android/lib/google_sign_in_android.dart index fbe10a4c9f9..bf021376182 100644 --- a/packages/google_sign_in/google_sign_in_android/lib/google_sign_in_android.dart +++ b/packages/google_sign_in/google_sign_in_android/lib/google_sign_in_android.dart @@ -290,7 +290,10 @@ class GoogleSignInAndroid extends GoogleSignInPlatform { // indirect way to get the associated email address that's not // deprecated. if (email != null) { - _cachedAccounts[email] = result.grantedScopes.first; + final String? scope = result.grantedScopes.firstOrNull; + if (scope != null) { + _cachedAccounts[email] = scope; + } } return ( accessToken: accessToken, From 35f4e0e7cbd62865ad853d78eae971b9f22dd78b Mon Sep 17 00:00:00 2001 From: Stuart Morgan Date: Wed, 24 Sep 2025 08:07:19 -0400 Subject: [PATCH 3/3] Extract a constant for Google account type --- .../flutter/plugins/googlesignin/GoogleSignInPlugin.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/GoogleSignInPlugin.java b/packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/GoogleSignInPlugin.java index c3b37fe74eb..1c3b8abc295 100644 --- a/packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/GoogleSignInPlugin.java +++ b/packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/GoogleSignInPlugin.java @@ -60,6 +60,9 @@ public class GoogleSignInPlugin implements FlutterPlugin, ActivityAware { private @Nullable BinaryMessenger messenger; private ActivityPluginBinding activityPluginBinding; + // The account type to use to create an Account object for a Google Sign In account. + private static final String GOOGLE_ACCOUNT_TYPE = "com.google"; + private void initInstance(@NonNull BinaryMessenger messenger, @NonNull Context context) { initWithDelegate( messenger, @@ -385,7 +388,7 @@ public void authorize( } if (params.getAccountEmail() != null) { authorizationRequestBuilder.setAccount( - new Account(params.getAccountEmail(), "com.google")); + new Account(params.getAccountEmail(), GOOGLE_ACCOUNT_TYPE)); } AuthorizationRequest authorizationRequest = authorizationRequestBuilder.build(); authorizationClientFactory @@ -468,7 +471,7 @@ public void revokeAccess( .create(context) .revokeAccess( RevokeAccessRequest.builder() - .setAccount(new Account(params.getAccountEmail(), "com.google")) + .setAccount(new Account(params.getAccountEmail(), GOOGLE_ACCOUNT_TYPE)) .setScopes(scopes) .build()) .addOnSuccessListener(unused -> ResultUtilsKt.completeWithUnitSuccess(callback))