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:
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:
kotlin
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)
}
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:
kotlin
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()
)
}
}
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.
kotlin
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 ?: "")
}
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.
kotlin
@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()
}
}
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:
- If the user is not logged in to any account on the Android device, it will fail.
- 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.
- 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:
To get here, we'll do the same as before:
- Call
registerForActivityResult()
- Launch an
Intent
And then we'll integrate the legacy auth flow as a fallback if any of the use cases listed.
- Call
registerForActivityResult()
``` kotlin fun getLegacyGoogleActivitySignInResultLauncher( fragment: Fragment, fragmentActivity: FragmentActivity, onIdToken: (String) -> Unit ): ActivityResultLauncher = 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) } }
2. 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()`.
kotlin
@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()
}
}
When you receive the `PendingIntent`, you can then use it to launch the Legacy Auth flow:
kotlin
fragmentActivity.lifecycleScope.launchWhenStarted {
val pendingIntent = beginSignInGoogleLegacy(
fragmentActivity,
clientId
)
val intentSenderRequest = IntentSenderRequest.Builder(pendingIntent.intentSender)
.build()
googleLegacyResultLauncher.launch(intentSenderRequest)
}
## 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](https://github.com/facebook/facebook-android-sdk/issues/875).
```kotlin
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)
}
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()
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)
}
})
}
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())
}
}
}
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)
}
}
}
}
Full Facebook auth:
Conclusion
To recap:
- The Legacy Google API covers all use cases, but is a bit more obtrusive.
- Google One Tap doesn't cover all use cases, but is less obtrusive.
- 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.
Top comments (0)