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()
override fun isAnonymousUser(): Boolean {
return (firebaseAuth.currentUser == null || firebaseAuth.currentUser!!.isAnonymous)
}
AuthResultCode is an enum which has the following values
enum class AuthResultCode {
NOT_APPLICABLE,
OK, CANCELLED,
MERGED,
NO_NETWORK,
ERROR,
LOGGING_OUT,
LOGGED_OUT
}
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)
}
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()
}
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
}
}
}
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
)
Initialize our state variables that we configured in the viewmodel
val isAnonymousUser by profileViewModel.isAnonymousUser.collectAsState()
val authResultCode by profileViewModel.authResultCode.collectAsState()
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)
}
}
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())
}
}
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" />
...
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
}
- 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
}
when (authResultCode) {
...
CANCELLED -> {
Log.d(TAG, "Cancelled auth state: navigating back")
navController.popBackStack()
}
...
}
- 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
}
}
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
}
}
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.