CredentialProviderGetSignInIntentController.kt

/*
 * Copyright 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.credentials.playservices.controllers.GetSignInIntent

import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.CancellationSignal
import android.os.Handler
import android.os.Looper
import android.os.ResultReceiver
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.credentials.Credential
import androidx.credentials.CredentialManagerCallback
import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetCredentialResponse
import androidx.credentials.exceptions.GetCredentialCancellationException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.GetCredentialInterruptedException
import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.exceptions.GetCredentialUnsupportedException
import androidx.credentials.playservices.CredentialProviderPlayServicesImpl
import androidx.credentials.playservices.HiddenActivity
import androidx.credentials.playservices.controllers.CredentialProviderBaseController
import androidx.credentials.playservices.controllers.CredentialProviderController
import com.google.android.gms.auth.api.identity.GetSignInIntentRequest
import com.google.android.gms.auth.api.identity.Identity
import com.google.android.gms.auth.api.identity.SignInCredential
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.common.api.CommonStatusCodes
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import java.util.concurrent.Executor

/**
 * A controller to handle the GetSignInIntent flow with play services.
 */
@Suppress("deprecation")
internal class CredentialProviderGetSignInIntentController(private val context: Context) :
    CredentialProviderController<GetCredentialRequest, GetSignInIntentRequest,
        SignInCredential, GetCredentialResponse, GetCredentialException>(context) {

    /**
     * The callback object state, used in the protected handleResponse method.
     */
    @VisibleForTesting
    lateinit var callback: CredentialManagerCallback<GetCredentialResponse, GetCredentialException>

    /**
     * The callback requires an executor to invoke it.
     */
    @VisibleForTesting
    lateinit var executor: Executor

    /**
     * The cancellation signal, which is shuttled around to stop the flow at any moment prior to
     * returning data.
     */
    @VisibleForTesting
    private var cancellationSignal: CancellationSignal? = null

    private val resultReceiver = object : ResultReceiver(
        Handler(Looper.getMainLooper())
    ) {
        public override fun onReceiveResult(
            resultCode: Int,
            resultData: Bundle
        ) {
            if (maybeReportErrorFromResultReceiver(
                    resultData,
                 CredentialProviderBaseController.Companion::getCredentialExceptionTypeToException,
                    executor = executor,
                    callback = callback,
                    cancellationSignal
                )
            ) return
            handleResponse(
                resultData.getInt(ACTIVITY_REQUEST_CODE_TAG),
                resultCode,
                resultData.getParcelable(RESULT_DATA_TAG)
            )
        }
    }

    override fun invokePlayServices(
        request: GetCredentialRequest,
        callback: CredentialManagerCallback<GetCredentialResponse, GetCredentialException>,
        executor: Executor,
        cancellationSignal: CancellationSignal?
    ) {
        this.cancellationSignal = cancellationSignal
        this.callback = callback
        this.executor = executor

        if (CredentialProviderPlayServicesImpl.cancellationReviewer(cancellationSignal)) {
            return
        }

        try {
            val convertedRequest: GetSignInIntentRequest =
                this.convertRequestToPlayServices(request)

            val hiddenIntent = Intent(context, HiddenActivity::class.java)
            hiddenIntent.putExtra(REQUEST_TAG, convertedRequest)
            generateHiddenActivityIntent(resultReceiver, hiddenIntent, SIGN_IN_INTENT_TAG)
            context.startActivity(hiddenIntent)
        } catch (e: Exception) {
            when (e) {
                is GetCredentialUnsupportedException ->
                    cancelOrCallbackExceptionOrResult(cancellationSignal) {
                        this.executor.execute {
                            this.callback.onError(e)
                        }
                    }
                else ->
                    cancelOrCallbackExceptionOrResult(cancellationSignal) {
                        this.executor.execute {
                            this.callback.onError(
                                GetCredentialUnknownException(ERROR_MESSAGE_START_ACTIVITY_FAILED)
                            )
                        }
                    }
            }
        }
    }

    @VisibleForTesting
    public override fun convertRequestToPlayServices(request: GetCredentialRequest):
        GetSignInIntentRequest {
        if (request.credentialOptions.count() != 1) {
            throw GetCredentialUnsupportedException(
                "GetSignInWithGoogleOption cannot be combined with other options."
            )
        }
        val option = request.credentialOptions[0] as GetSignInWithGoogleOption
        return GetSignInIntentRequest.builder()
            .setServerClientId(option.serverClientId)
            .filterByHostedDomain(option.hostedDomainFilter)
            .setNonce(option.nonce)
            .build()
    }

    override fun convertResponseToCredentialManager(response: SignInCredential):
        GetCredentialResponse {
        var cred: Credential? = null
        if (response.googleIdToken != null) {
            cred = createGoogleIdCredential(response)
        } else {
            Log.w(TAG, "Credential returned but no google Id found")
        }
        if (cred == null) {
            throw GetCredentialUnknownException(
                "When attempting to convert get response, " + "null credential found"
            )
        }
        return GetCredentialResponse(cred)
    }

    @VisibleForTesting
    fun createGoogleIdCredential(response: SignInCredential): GoogleIdTokenCredential {
        var cred = GoogleIdTokenCredential.Builder().setId(response.id)
        try {
            cred.setIdToken(response.googleIdToken!!)
        } catch (e: Exception) {
            throw GetCredentialUnknownException(
                "When attempting to convert get response, " + "null Google ID Token found"
            )
        }

        if (response.displayName != null) {
            cred.setDisplayName(response.displayName)
        }

        if (response.givenName != null) {
            cred.setGivenName(response.givenName)
        }

        if (response.familyName != null) {
            cred.setFamilyName(response.familyName)
        }

        if (response.phoneNumber != null) {
            cred.setPhoneNumber(response.phoneNumber)
        }

        if (response.profilePictureUri != null) {
            cred.setProfilePictureUri(response.profilePictureUri)
        }

        return cred.build()
    }

    internal fun handleResponse(uniqueRequestCode: Int, resultCode: Int, data: Intent?) {
        if (uniqueRequestCode != CONTROLLER_REQUEST_CODE) {
            Log.w(
                TAG,
                "Returned request code $CONTROLLER_REQUEST_CODE which " +
                    " does not match what was given $uniqueRequestCode"
            )
            return
        }
        if (maybeReportErrorResultCodeGet(
                resultCode,
                { s, f -> cancelOrCallbackExceptionOrResult(s, f) },
                { e ->
                    this.executor.execute {
                        this.callback.onError(e)
                    }
                },
                cancellationSignal
            )
        ) return
        try {
            val signInCredential =
                Identity.getSignInClient(context).getSignInCredentialFromIntent(data)
            val response = convertResponseToCredentialManager(signInCredential)
            cancelOrCallbackExceptionOrResult(cancellationSignal) {
                this.executor.execute {
                    this.callback.onResult(response)
                }
            }
        } catch (e: ApiException) {
            var exception: GetCredentialException = GetCredentialUnknownException(e.message)
            if (e.statusCode == CommonStatusCodes.CANCELED) {
                exception = GetCredentialCancellationException(e.message)
            } else if (e.statusCode in retryables) {
                exception = GetCredentialInterruptedException(e.message)
            }
            cancelOrCallbackExceptionOrResult(cancellationSignal) {
                executor.execute {
                    callback.onError(exception)
                }
            }
            return
        } catch (e: GetCredentialException) {
            cancelOrCallbackExceptionOrResult(cancellationSignal) {
                executor.execute {
                    callback.onError(e)
                }
            }
        } catch (t: Throwable) {
            val e = GetCredentialUnknownException(t.message)
            cancelOrCallbackExceptionOrResult(cancellationSignal) {
                executor.execute {
                    callback.onError(e)
                }
            }
        }
    }

    companion object {
        private const val TAG = "GetSignInIntent"
        private var controller: CredentialProviderGetSignInIntentController? = null

        /**
         * This finds a past version of the [CredentialProviderGetSignInIntentController] if it exists,
         * otherwise it generates a new instance.
         *
         * @param context the calling context for this controller
         * @return a credential provider controller for a specific begin sign in credential request
         */
        @JvmStatic
        fun getInstance(context: Context): CredentialProviderGetSignInIntentController {
            if (controller == null) {
                controller = CredentialProviderGetSignInIntentController(context)
            }
            return controller!!
        }
    }
}