DEV Community

loading...
Cover image for Google and Facebook Login on Android with Coroutines
Touchlab

Google and Facebook Login on Android with Coroutines

Brady Aiello
An Android developer who fell down the rabbit hole of Kotlin Multiplatform
・7 min read

Intro

I recently started using Google One Tap and Facebook login for some auth work for one of our clients at Touchlab, and wanted to make sure I was using the recommended best practices using coroutines and the new Activity Result API. This was trickier than I realized, because the docs for Google One Tap are out of date, and the API for Facebook login is out of date. So after figuring it out, I wanted to share some code snippets for my future self, and I hope you get something useful from it as well.

Additionally, I've found a use case that Google One Tap does not support, and a solution for getting around it, so to assure your users have an optimal sign-up and sign-in experience. Let's dive in!

Google One Tap

One Tap is a cool way to sign up or sign in a user to your app pretty seamlessly. On sign up, you authorize the app to continue, and if you've already authorized the app, you can just just choose an account, and continue on.
Sign Up:
Google One Tap Sign Up
Sign In:
Google One Tap Sign In

Google's Sean McQuillan writes a great overview of the benefits of One Tap over the normal Google sign in API here. The One Tap docs are pretty complete, but they start getting out of date here. The demo code:

oneTapClient.beginSignIn(signInRequest)
    .addOnSuccessListener(this) { result ->
        try {
            startIntentSenderForResult(
                result.pendingIntent.intentSender, REQ_ONE_TAP,
                null, 0, 0, 0, null)
        } catch (e: IntentSender.SendIntentException) {
            Log.e(TAG, "Couldn't start One Tap UI: ${e.localizedMessage}")
        }
    }
    .addOnFailureListener(this) { e ->
        // No saved credentials found. Launch the One Tap sign-up flow, or
        // do nothing and continue presenting the signed-out UI.
        Log.d(TAG, e.localizedMessage)
    }
Enter fullscreen mode Exit fullscreen mode

There are a couple issues here. We want to use coroutines, so we'll need to wrap the beginSignIn() call with suspendCancellableCoroutine{}. That's pretty straightforward. The second thing we notice is that the 7-parameter startIntentSenderForResult() method is deprecated. Instead, the new way is to call launch() on an instance of ActivityResultLauncher<IntentSenderRequest> , passing an instance of IntentSenderRequest as a parameter. It sounds complicated, but it's not bad. And the nice part is that, by tying the callback to the intent sender itself, we don't need to add unique result integers to determine who sent the intent.

That part will look something like:

val intentSender: ActivityResultLauncher<IntentSenderRequest> =
    registerForActivityResult(
        StartIntentSenderForResult()
    ) { activityResult ->
        val data = activityResult.data
        val credential: SignInCredential =
            oneTapClient.getSignInCredentialFromIntent(data)
        Log.d("Credential", credential.googleIdToken.toString())
        activity?.lifecycleScope?.launchWhenStarted {                  
            loginViewModel
                .loginToOurServerWithGoogle(
                    credential.googleIdToken.toString()
                )
        }
    }
Enter fullscreen mode Exit fullscreen mode

NOTE: It's crucial that you call registerForActivityResult() when the Fragment or Activity is created. The app will crash if you register your callback any other time.

We've registered for the Activity Result, so now we need to actually launch the Intent. We'll start by calling beginSignIn(), and adding a success and failure callback.

oneTapClient.beginSignIn(signInRequest)
        .addOnSuccessListener(fragmentActivity) { result ->
            try {
                intentSender.launch(
                    IntentSenderRequest
                        .Builder(result.pendingIntent.intentSender)
                        .build()
                )
            } catch (e: IntentSender.SendIntentException) {
                Log.e(
                    "SignUp UI",
                    "Couldn't start One Tap UI: ${e.localizedMessage}"
                )
            }
        }
        .addOnFailureListener(fragmentActivity) { e ->
            // Maybe no Google Accounts found
            Log.d("SignUp UI", e.localizedMessage ?: "")
        }
Enter fullscreen mode Exit fullscreen mode

And to coroutines-ify it, we'll wrap those callbacks in suspendCancellableCoroutine(). Also, instead of executing our callback right here, we'll just resume with the result, which we'll use later.

@ExperimentalCoroutinesApi
suspend fun beginSignInGoogleOneTap(
    fragmentActivity: FragmentActivity,
    oneTapClient: SignInClient,
    signInRequest: BeginSignInRequest,
    onCancel: () -> Unit
    ): BeginSignInResult =
    suspendCancellableCoroutine { continuation ->
        oneTapClient.beginSignIn(signInRequest)
            .addOnSuccessListener(fragmentActivity) { result ->
                continuation.resume(result) { throwable ->
                    Log.e("SignUp UI", "beginSignInGoogleOneTap: ", throwable)
                }
            }
            .addOnFailureListener(fragmentActivity) { e ->
                // No Google Accounts found. Just continue presenting the signed-out UI.
                continuation.resumeWithException(e)
            }
            .addOnCanceledListener {
                Log.d("SignUp UI", "beginSignInGoogleOneTap: cancelled")
                onCancel()
                continuation.cancel()
            }
    }
Enter fullscreen mode Exit fullscreen mode

The full code isn't much more than that. Full gist:

Classic Google Sign-In

As awesome as Google One Tap is, there are a few uncovered use cases:

  1. If the user is not logged in to any account on the Android device, it will fail.
  2. If the user wants to sign up or sign in using an account that is currently not signed in on the Android device, it will fail.
  3. If the user has cancelled the One Tap auth too many times, the user will be unable to start the One Tap auth flow for 24 hours.

To cover these common use cases, we can use the classic Google Sign-in API as a backup. This makes the auth flow more complex, but gives the user the most flexibility:

Full Google Auth Flow

To get here, we'll do the same as before:

  1. Call registerForActivityResult()
  2. Launch an Intent

And then we'll integrate the legacy auth flow as a fallback if any of the use cases listed.

  1. Call registerForActivityResult()
fun getLegacyGoogleActivitySignInResultLauncher(
    fragment: Fragment,
    fragmentActivity: FragmentActivity,
    onIdToken: (String) -> Unit
): ActivityResultLauncher<IntentSenderRequest> =
    fragment.registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { activityResult ->
        try {
            val token: String? = Identity.getSignInClient(fragmentActivity)
                .getSignInCredentialFromIntent(activityResult.data)?.googleIdToken
            if (token != null) {
                onIdToken(token)
                Log.d(TAG, "getLegacyGoogleActivitySignInResultLauncher: $token")
            }
        } catch (e: Exception) {
            Log.e(fragment::class.java.toString(), e.toString(), e)
        }
    }
Enter fullscreen mode Exit fullscreen mode
  1. Launch an Intent. Like One Tap, the classic Google Sign-In API returns a Task to which we attach callbacks. Like before, we'll wrap the callbacks with suspendCancellableCoroutine().
@ExperimentalCoroutinesApi
suspend fun beginSignInGoogleLegacy(
    fragmentActivity: FragmentActivity,
    clientId: String,
): PendingIntent =
    suspendCancellableCoroutine { continuation ->
        val request: GetSignInIntentRequest = GetSignInIntentRequest.builder()
            .setServerClientId(clientId)
            .build()

        Identity.getSignInClient(fragmentActivity)
            .getSignInIntent(request)
            .addOnSuccessListener { pendingIntent ->
                continuation.resume(pendingIntent) { throwable ->
                    Log.e(TAG, "beginSignInGoogleLegacy: ", throwable )
                }
            }
            .addOnFailureListener { exception ->
                Log.e(TAG, "beginSignInGoogleLegacy", exception)
                continuation.resumeWithException(exception)
            }
            .addOnCanceledListener {
                Log.d(TAG, "beginSignInGoogleLegacy: cancelled")
                continuation.cancel()
            }
    }

Enter fullscreen mode Exit fullscreen mode

When you receive the PendingIntent, you can then use it to launch the Legacy Auth flow:

fragmentActivity.lifecycleScope.launchWhenStarted {
    val pendingIntent = beginSignInGoogleLegacy(
        fragmentActivity,
        clientId
    )
    val intentSenderRequest = IntentSenderRequest.Builder(pendingIntent.intentSender)
        .build()
    googleLegacyResultLauncher.launch(intentSenderRequest)
}
Enter fullscreen mode Exit fullscreen mode

Using the Legacy Google Auth as Backup for One Tap

So, we can exit the One Tap flow in the following ways:

Case 1: The user isn't logged in to any account on the device

If this is the case, One Tap will throw an ApiException in a failureListener if you have one attached, with cause, Cannot find a matching credential. We can also confirm this at any time by grabbing the last token used and checking if it's null:
GoogleSignIn.getLastSignedInAccount(activity)?.idToken In this case, we'll need to use the Legacy Google Auth API.

Case 2: One Tap has been cancelled too many times.

If this happens, we won't be able to use One Tap for 24 hours and get an
ApiException with cause: Caller has been temporarily blacklisted due to too many canceled sign-in prompts. We can wrap this in a try/catch block, and seamlessly transition to the legacy Google auth API. When developing, I've found it helpful to get around the One Tap ban by wiping the emulator data and cold booting it.

Case 3: The user cancels One Tap manually.

This can be done by clicking the "Cancel" button when it starts loading, or by navigating back. This can be either because they don't wish to authenticate at all, or because they want to authenticate with an account that isn't listed. We don't know for sure, so we'll fall back to the Legacy Google Auth API in the case they want to authenticate with an account that hasn't been added to their Android device. If they don't want to authenticate at all, they can cancel this auth flow as well.

The full example:

https://gist.github.com/brady-aiello/dc00cf160214812c38ec64f94e820cfb

Facebook Login

Facebook auth still uses a callback-style approach, so we'll need to wrap the calls in suspendCancellableCoroutine{} as well. Unlike Google Auth, however, the Facebook Auth library has not updated to use registerForActivityResult{}. Instead, we'll need to use the deprecated way: overriding onActivityResult() in our Activity. You can follow the ticket here.

override fun onActivityResult(
    requestCode: Int,
    resultCode: Int,
    data: Intent?
) {
    loginViewModel
        .callbackManager
        .onActivityResult(requestCode, resultCode, data)
    // onActivityResult() is deprecated, 
    // but Facebook hasn't added support for 
    // the new Activity Contracts API yet.
    // https://github.com/facebook/facebook-android-sdk/issues/875
    super.onActivityResult(requestCode, resultCode, data)
}
Enter fullscreen mode Exit fullscreen mode

We're keeping the CallbackManager in a shared ViewModel so we can reuse the same one regardless of being in the LoginFragment or the CreateAccountFragment. Creating one is straightforward:

val callbackManager: CallbackManager = CallbackManager.Factory.create()
Enter fullscreen mode Exit fullscreen mode

This all just passes along the result of launching Facebook's auth Activity so we can do something with the result. Let's make a coroutines-friendly way of getting the LoginResult.

@ExperimentalCoroutinesApi
suspend fun getFacebookToken(callbackManager: CallbackManager): LoginResult =
    suspendCancellableCoroutine { continuation ->
        LoginManager.getInstance()
            .registerCallback(callbackManager, object :
                FacebookCallback<LoginResult> {

                override fun onSuccess(loginResult: LoginResult) {
                    continuation.resume(loginResult){ }
                }

                override fun onCancel() {
                    // handling cancelled flow (probably don't need anything here)
                    continuation.cancel()
                }

                override fun onError(exception: FacebookException) {
                    // Facebook authorization error
                    continuation.resumeWithException(exception)
                }
            })
    }
Enter fullscreen mode Exit fullscreen mode

And then we can pass the result along:

@ExperimentalCoroutinesApi
fun FragmentActivity.finishFacebookLoginToThirdParty(
    onCredential: suspend (LoginResult) -> Unit
    ) {
    this.lifecycleScope.launchWhenStarted {
        try {
            val loginResult: LoginResult = getFacebookToken(loginViewModel.callbackManager)
            onCredential(loginResult)
        } catch (e: FacebookException) {
            Log.e("Facebook Error", e.toString())
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And we can tie it all together with the UI

@ExperimentalCoroutinesApi
private fun setupFacebookContinueButton() {
    fragmentLoginBinding?.buttonContinueFacebook?.setOnClickListener {
        activity?.let { fragmentActivity ->
            LoginManager.getInstance().logInWithReadPermissions(fragmentActivity, listOf("email"))
            fragmentActivity.finishFacebookLoginToThirdParty { loginResult ->
                loginViewModel.loginFacebook(loginResult.accessToken.token)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Full Facebook auth:

Conclusion

To recap:

  1. The Legacy Google API covers all use cases, but is a bit more obtrusive.
  2. Google One Tap doesn't cover all use cases, but is less obtrusive.
  3. Use the Legacy Google Auth API as a backup, either when the user wants to log in with a different account, or when the user isn't signed in to any Google account on the device.

That's all, folks. How are you handling auth? Are you sticking with Google's Legacy Auth to keep things simple? Just having Firebase handle it for you? Let us know in the comments. This is Brady, signing off and signing out.

Discussion (0)