Configuring Firebase Auth UI With Jetpack Compose

Configuring Firebase Auth UI With Jetpack Compose

Configuring Firebase UI for authentication is straightforward, similar to how we set it up in the view system but with few minor changes. Let's look at how we can configure Firebase UI when using Jetpack Compose.

Gradle Setup

Gradle setup is basically following the documentation to add Firebase to the project and adding the below dependency for the UI authentication.

dependencies {
  // ...

   implementation 'com.firebaseui:firebase-ui-auth:7.2.0'

  //...
}

Configuring State in View Model

We will be using the following state variables, in our ProfileViewModel to perform recomposition on the composable functions based on the authentication state of the user.

//Used to decide if we need to launch the login intent
private val _isAnonymousUser = MutableStateFlow(userRepository.isAnonymousUser())
val isAnonymousUser = _isAnonymousUser.asStateFlow()

//Used to perform appropriate action based on the login result
private val _authResultCode = MutableStateFlow(AuthResultCode.NOT_APPLICABLE)
val authResultCode = _authResultCode.asStateFlow()
ProfileViewModel.kt
override fun isAnonymousUser(): Boolean {
  return (firebaseAuth.currentUser == null || firebaseAuth.currentUser!!.isAnonymous)
}
UserRepository.kt

AuthResultCode is an enum which has the following values

enum class AuthResultCode {
    NOT_APPLICABLE,
    OK, CANCELLED,
    MERGED,
    NO_NETWORK,
    ERROR,
    LOGGING_OUT,
    LOGGED_OUT
}
AuthResultCode.kt

Build Login Intent

The next step would be to build our login intent, but before we do this, let's look at defining an interface which we could implement in our ProfileViewModel.

interface FirebaseAuthManager {

    fun buildLoginIntent(): Intent
    fun buildLoginActivityResult(): FirebaseAuthUIActivityResultContract =
        FirebaseAuthUIActivityResultContract()

     fun onLoginResult(result: FirebaseAuthUIAuthenticationResult)
}
FirebaseAuthManager.kt

Notice that the method buildLoginActivityResult on the interface has a default implementation for the Firebase UI Activity result contract. ProfileViewModel implements the remaining two methods on the above interface.

  • buildLoginIntent: we create the sign in intent with our preferred sign-in methods:
override fun buildLoginIntent(): Intent {

        val authUILayout = AuthMethodPickerLayout.Builder(R.layout.auth_ui)
            .setGoogleButtonId(R.id.btn_gmail)
            .setEmailButtonId(R.id.btn_email)
            .build()

        return AuthUI.getInstance().createSignInIntentBuilder()
            .setAvailableProviders(
                listOf(
                    AuthUI.IdpConfig.EmailBuilder().build(),
                    AuthUI.IdpConfig.GoogleBuilder().build()
                )
            )
            .enableAnonymousUsersAutoUpgrade()
            .setLogo(R.mipmap.ic_launcher)
            .setAuthMethodPickerLayout(authUILayout)
            .build()
}
ProfileViewModel.kt

For my app, I use a custom auth layout.

  • onLoginResult: This is where we will recieve the result once the sign-in flow is complete. I also handle linking of anonymous user data to the signed in account as well.
override fun onLoginResult(result: FirebaseAuthUIAuthenticationResult) {

        Log.d(TAG, "onLoginResult triggered")
        Log.d(TAG, result.toString())


        val response: IdpResponse? = result.idpResponse
        if (result.resultCode == Activity.RESULT_OK) {

            viewModelScope.launch {
                setUser()
            }

            _isAnonymousUser.value = false
            _authResultCode.value = AuthResultCode.OK

            Log.d(TAG, "Login successful")
            return
        }


        val userPressedBackButton = (response == null)
        if (userPressedBackButton) {
            _authResultCode.value = AuthResultCode.CANCELLED
            Log.d(TAG, "Login cancelled by user")
            return
        }

        when (response?.error?.errorCode) {
            ErrorCodes.NO_NETWORK -> {
                _authResultCode.value = AuthResultCode.NO_NETWORK

                Log.d(TAG, "Login failed on network connectivity")
            }
            ErrorCodes.ANONYMOUS_UPGRADE_MERGE_CONFLICT -> {

                val nonAnonymousCredForLinking: AuthCredential =
                    response.credentialForLinking!!

                viewModelScope.launch {
                    handleMergeConflict(nonAnonymousCredForLinking = nonAnonymousCredForLinking)
                }
            }
            else -> {
                Log.d(TAG, "Login failed")
                _authResultCode.value = AuthResultCode.ERROR
            }


        }
    }
ProfileViewModel.kt

The above method handles the different scenarios that could happen on the sign-in result. This would than allow the composable to perform the desired actions. We will come back to this shortly once we look at our Composable function.

Define our Profile UI Composable

Since our composable root function for ProfileUI accepts ProfileViewModel, we will define our actions tied to the authentication process in this function. The child composable functions are responsible for building the UI and sending the events up to this root function.

@ExperimentalCoroutinesApi
@Composable
fun ProfileUI(
    navController: NavController, scaffoldState: ScaffoldState,
    authViewModel: AuthViewModel, profileViewModel: ProfileViewModel
)
ProfileUI.kt

Initialize our state variables that we configured in the viewmodel

val isAnonymousUser by profileViewModel.isAnonymousUser.collectAsState()
val authResultCode by profileViewModel.authResultCode.collectAsState()
ProfileUI.kt

Initialize the login launcher by using rememberLauncherForActivityResult. This registers the callback for the result, which we than use to call the onLoginResult method on our view model.

val loginLauncher = rememberLauncherForActivityResult(
        profileViewModel.buildLoginActivityResult()
) { result ->
        if (result != null) {
            profileViewModel.onLoginResult(result = result)
        }
}
ProfileUI.kt

Launching FirebaseUI Sign-in Flow

Now that we have all the pieces in place, the next step would be to launch the FirebaseUI sign-in flow. This could be done depending on your app requirements. For my app, I wanted to show the login UI anytime an anonymous user selects the Profile tab in the bottom navigation.

We can use the below code to launch sign-in flow which only runs if the user is anonymous

if (isAnonymousUser && authResultCode != CANCELLED) {
     LaunchedEffect(true)  {
       loginLauncher.launch(profileViewModel.buildLoginIntent())
      }
} 
ProfileUI.kt

Here LaunchedEffect is critical for the sign-in flow to run as expected

Once the flow is launched we should see the familiar FirebaseUI to perform the sign in process.

When using a custom auth layout, I noticed that the Firebase UI styles on the buttons are no longer working with MDC and I had to specify the styles

Here is how I have changed some of the styles to match the original styles. If there is a better way of doing this, I'm all ears but hoping this will be fixed in the newer versions of MDC.

 ...
 
 <Button
        android:id="@+id/btn_email"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:backgroundTint="@color/fui_bgEmail"
        android:fontFamily="sans-serif-medium"
        android:text="@string/fui_sign_in_with_email"
        android:textAllCaps="false"
        android:textColor="@color/white"
        android:textSize="14sp"
        android:textStyle="bold"
        app:icon="@drawable/fui_ic_mail_white_24dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.73" />

<Button
        android:id="@+id/btn_gmail"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="32dp"
        android:backgroundTint="@color/white"
        android:fontFamily="sans-serif-medium"
        android:text="@string/fui_sign_in_with_google"
        android:textAllCaps="false"
        android:textColor="#757575"
        android:textStyle="bold"
        app:icon="@drawable/fui_ic_googleg_color_24dp"
        app:iconTint="@null"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/btn_email" />
        
  ...
auth_ui.xml

Handling Login Result

In the previous section where we defined our login intent result, there is  quite a bit going on there, so let's look at how we handle the outcome of each scenario.

  • Success: In the success state we just want to retrieve the user information and display the Profile UI with the user details. So we change the state variables in our view model so the UI can recompose. In my example, I used the setUser function to set another state variable that holds the user information.
if (result.resultCode == Activity.RESULT_OK) {

            viewModelScope.launch {
                setUser()
            }

            _isAnonymousUser.value = false
            _authResultCode.value = AuthResultCode.OK

            Log.d(TAG, "Login successful")
            return
}
ProfileViewModel.kt
  • Cancellation: When the user has cancelled the flow this would produce an empty response. We use this to update our AuthResultCode state variable and the UI can handle the desired actions. For my app, I just perform navController.popBackStack()to go back to the previous UI.
val userPressedBackButton = (response == null)
if (userPressedBackButton) {
    _authResultCode.value = AuthResultCode.CANCELLED
    Log.d(TAG, "Login cancelled by user")
    return
}
ProfileViewModel.kt
when (authResultCode) {

...
  CANCELLED -> {
     Log.d(TAG, "Cancelled auth state: navigating back")
     navController.popBackStack()
   }
...

}
ProfileUI.kt
  • Failure: When there is an error, it could mainly either be due to a merge conflict or a network issue. If you handle merge conflict you would need to link the credential by running a suspend function and merging the user's anonymous data into the users account.
when (response?.error?.errorCode) {
    ErrorCodes.NO_NETWORK -> {
                _authResultCode.value = AuthResultCode.NO_NETWORK

                Log.d(TAG, "Login failed on network connectivity")
            }
    ErrorCodes.ANONYMOUS_UPGRADE_MERGE_CONFLICT -> {

                val nonAnonymousCredForLinking: AuthCredential =
                    response.credentialForLinking!!

                viewModelScope.launch {
                    handleMergeConflict(nonAnonymousCredForLinking = nonAnonymousCredForLinking)
                }
            }
            else -> {
                Log.d(TAG, "Login failed")
                _authResultCode.value = AuthResultCode.ERROR
            }
        }
ProfileViewModel.kt

Handling merge conflict

private suspend fun handleMergeConflict(nonAnonymousCredForLinking: AuthCredential) {

        Log.d(TAG, "Login failed on merge conflict. Trying to link credentials..")
        val anonymousUserId = auth.currentUser?.uid!!
        try {
            auth.signInWithCredential(nonAnonymousCredForLinking).await()

            Log.d(TAG, "Merge successful")
            //To do: Merge user data
            setUser()
            _isAnonymousUser.value = false
            _authResultCode.value = AuthResultCode.MERGED
        } catch (ex: Exception) {
            _authResultCode.value = AuthResultCode.ERROR
        }
    }
ProfileViewModel.kt

Hope this helps you with implementing Firebase Auth UI in your app. Let me know if you have any questions on the above implementation or if there are ways to improve this code further in the comments below.