DEV Community

Cover image for Firebase Authentication With Jetpack Compose. Part 1
Yauheni Mookich
Yauheni Mookich

Posted on • Updated on

Firebase Authentication With Jetpack Compose. Part 1

Hey! This post is part of a series of building an expense tracker app. In today's guide we're going to implement firebase authentication with input verification in jetpack compose. Also, this post features the usage of CompositionLocalProvider, which enables a clear and reusable way of showing a snackbar instead of injecting a separate callback into composables which is very junky. In one of the next guides I'll show you how to implement Google sign in, UI, and Unit testing of authentication, so stay tuned. If you have any questions or suggestions feel free to leave them in comments

Here's how the end result might look like

Image description

And the source code is here


Firebase setup

Head on to firebase and click on add project
Add project
After filling in the required info start configuring the project for Android by clicking on the corresponding icon
Configure for Android
After following instructions head on to Authentication tab and add Email/Password provider
Enable Email/Password authentication


Hilt setup

Hilt is a popular dependency injection tool which simplifies inserting dependencies (classes, objects, mocks, etc.) into other dependencies.

You can pick versions of plugins that suit you here

Insert the following code into your root build.gradle file

plugins {
  id("com.google.dagger.hilt.android") version "2.50" apply false
// compatible with the 1.9.22 version of Kotlin
  id("com.google.devtools.ksp") version "1.9.22-1.0.16" apply false
}
Enter fullscreen mode Exit fullscreen mode

After syncing gradle, apply the below dependencies in your app/build.gradle file

plugins {
  id("com.google.dagger.hilt.android")
  id("com.google.devtools.ksp")
}
dependencies {
  implementation("com.google.dagger:hilt-android:2.50")
// required if using navigation together with hilt
  implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
  ksp("com.google.dagger:hilt-compiler:2.50")
  ksp("com.google.dagger:hilt-android-compiler:2.50")
}
Enter fullscreen mode Exit fullscreen mode

Then annotate your MainActivity class with @AndroidEntryPoint

@AndroidEntryPoint
class MainActivity : ComponentActivity()
Enter fullscreen mode Exit fullscreen mode

And last, create a ${YourAppName}Application.kt file and insert the following code

@HiltAndroidApp
// name it however you want
class MyApplication: Application()
Enter fullscreen mode Exit fullscreen mode

To learn more about Hilt, visit this page:


Repository and View Model

First let's create a AuthUseCases.kt file and put 3 input validators

class UsernameValidator {
    operator fun invoke(username: String): UsernameValidationResult {
        return if (username.isBlank()) UsernameValidationResult.IS_EMPTY
        else UsernameValidationResult.CORRECT
    }
}
class EmailValidator {
    operator fun invoke(email: String): EmailValidationResult {
        return if (Patterns.EMAIL_ADDRESS.matcher(email).matches()) {
            EmailValidationResult.CORRECT
        }
        else EmailValidationResult.INCORRECT_FORMAT
    }
}

class PasswordValidator {
    operator fun invoke(password: String): PasswordValidationResult {
        return if (password.length < 8) PasswordValidationResult.NOT_LONG_ENOUGH
        else if (password.count(Char::isUpperCase) == 0) PasswordValidationResult.NOT_ENOUGH_UPPERCASE
        else if (!password.contains("[0-9]".toRegex())) PasswordValidationResult.NOT_ENOUGH_DIGITS
        else PasswordValidationResult.CORRECT
    }
}
enum class UsernameValidationResult {
    IS_EMPTY,
    CORRECT
}
enum class EmailValidationResult {
    INCORRECT_FORMAT,
    CORRECT
}
enum class PasswordValidationResult {
    NOT_LONG_ENOUGH,
    NOT_ENOUGH_DIGITS,
    NOT_ENOUGH_UPPERCASE,
    CORRECT
}
Enter fullscreen mode Exit fullscreen mode

operator fun invoke() is a special type of function in Kotlin that allows us to insert parameters into a class instance. Here's an example

val passwordValidator = PasswordValidator()
passwordValidator(password)
Enter fullscreen mode Exit fullscreen mode

For email validation we're using an email address checker of android.util package that returns true if a given email passes the check
In password validator, password.contains("[0-9]".toRegex()) returns true if a given input contains at least 1 number, otherwise, we will inform a user of this requirement


Next, we'll create a AuthRepository.kt file that will consist of a class responsible for authentication, which we'll later inject into our view model

class AuthRepository(
    private val auth: FirebaseAuth,
    private val firestore: FirebaseFirestore) {
    suspend fun signUp(authState: AuthState) {
       auth.createUserWithEmailAndPassword(authState.email!!, authState.password!!).await()
       auth.currentUser?.updateProfile(UserProfileChangeRequest.Builder()
            .setDisplayName(authState.username!!).build())?.await()
    }
    suspend fun signIn(authState: AuthState) {
        auth.signInWithEmailAndPassword(authState.email!!, authState.password!!).await()
    }
}
Enter fullscreen mode Exit fullscreen mode

The code above contains a function that creates a user with email, password, and a username, which is set with the help of UserProfileChangeRequest.


Next, create a CoroutineScopeProvider.kt file and define the class that will come in handy during view model testing, as with it we'll be able to use TestScope in our view model

class CoroutineScopeProvider(private val coroutineScope: CoroutineScope? = null) {
    fun provide() = coroutineScope
}
Enter fullscreen mode Exit fullscreen mode

Create a StringValue.kt file and define the StringValue class. It will enable us to match input validation results to strings in an easy and clean way

sealed class StringValue {

    data class DynamicString(val value: String) : StringValue()

    data object Empty : StringValue()

    class StringResource(
        @StringRes val resId: Int
    ) : StringValue()

    fun asString(context: Context): String {
        return when (this) {
            is Empty -> ""
            is DynamicString -> value
            is StringResource -> context.getString(resId))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Create a AuthModule.kt file and define AuthModule, that will tell hilt which instance of AuthRepository and CoroutineScopeProvider to provide (e.g mocked or real)

@Module
@InstallIn(SingletonComponent::class)
object AuthModule {
    @Provides
    @Singleton
    fun provideAuthRepository(): AuthRepository =
        AuthRepository(Firebase.auth, Firebase.firestore)

    @Provides
    @Singleton
    fun provideCoroutineScopeProvider(): CoroutineScopeProvider =
        CoroutineScopeProvider()
}
Enter fullscreen mode Exit fullscreen mode

Singleton components are created only once per lifecycle of the application. Since there's absolutely no need to provide a different instance of AuthRepository and CoroutineScopeProvider each time they're requested, using @Singleton is a perfect choice.


Create a Result.kt file containing the Result class.

sealed class CustomResult(val error: StringValue = StringValue.Empty) {
    data object Idle: CustomResult()
    data object InProgress: CustomResult()
    data object Empty: CustomResult()
    data object Success: CustomResult()
    class DynamicError(error: String): CustomResult(StringValue.DynamicString(error))
    class ResourceError(@StringRes res: Int): CustomResult(StringValue.StringResource(res))
}
Enter fullscreen mode Exit fullscreen mode

This class will be responsible for providing smooth user experience as well as other important features (like disabling auth fields and buttons when the result of a sign in operation is InProgress)


After that, create a AuthViewModel.kt file and start defining auth view model

@HiltViewModel
class AuthViewModel @Inject constructor(
    private val authRepository: AuthRepository,
    coroutineScopeProvider: CoroutineScopeProvider
): ViewModel() {
    private val usernameValidator = UsernameValidator()
    private val emailValidator = EmailValidator()
    private val passwordValidator = PasswordValidator()
    private val scope = coroutineScopeProvider.provide() ?: viewModelScope
    private val _uiState = MutableStateFlow(UiState())
    val uiState = _uiState.asStateFlow()

    fun onUsername(username: String) {
        val result = when (usernameValidator(username)) {
            UsernameValidationResult.IS_EMPTY -> StringValue.StringResource(R.string.username_not_long_enough)
            else -> StringValue.Empty
        }
        _uiState.update { it.copy(validationState = it.validationState.copy(usernameValidationError = result),
            authState = it.authState.copy(username = username)) }
    }
    fun onEmail(email: String) {
        val result = when (emailValidator(email)) {
            EmailValidationResult.INCORRECT_FORMAT -> StringValue.StringResource(R.string.invalid_email_format)
            else -> StringValue.Empty
        }
        _uiState.update { it.copy(validationState = it.validationState.copy(emailValidationError = result),
            authState = it.authState.copy(email = email)) }
    }
    fun onPassword(password: String) {
        val result = when (passwordValidator(password)) {
            PasswordValidationResult.NOT_LONG_ENOUGH -> StringValue.StringResource(R.string.password_not_long_enough)
            PasswordValidationResult.NOT_ENOUGH_UPPERCASE -> StringValue.StringResource(R.string.password_not_enough_uppercase)
            PasswordValidationResult.NOT_ENOUGH_DIGITS -> StringValue.StringResource(R.string.password_not_enough_digits)
            else -> StringValue.Empty
        }
        _uiState.update { it.copy(validationState = it.validationState.copy(passwordValidationError = result),
            authState = it.authState.copy(password = password)) }
    }
    data class UiState(
        val authType: AuthType = AuthType.SIGN_IN,
        val authState: AuthState = AuthState(),
        val validationState: ValidationState = ValidationState(),
        val authResult: CustomResult = CustomResult.Idle)
    data class ValidationState(
        val usernameValidationError: StringValue = StringValue.Empty,
        val emailValidationError: StringValue = StringValue.Empty,
        val passwordValidationError: StringValue = StringValue.Empty,
    )
}
data class AuthState(
    val username: String? = null,
    val email: String? = null,
    val password: String? = null,
)
enum class AuthType {
    SIGN_IN,
    SIGN_UP
}
enum class FieldType {
    USERNAME,
    EMAIL,
    PASSWORD
}
Enter fullscreen mode Exit fullscreen mode

In this code AuthViewModel is annotated with @HiltViewModel, and its constructor is annotated with @Inject, which makes hilt inject the dependencies we defined in AuthModule.
onUsername, onEmail, and onPassword functions are responsible for updating auth fields and the appropriate verification results. These will be called every time a user types something in.
Depending on the value of authType variable we'll decide what kind of authentication is currently chosen (sign in or sign up), and make the UI and authentication pipeline react accordingly.


Now lets add a couple of functions to AuthViewModel, which are directly responsible for authenticating a user

fun changeAuthType() {
        _uiState.update { it.copy(authType = AuthType.entries[it.authType.ordinal xor 1]) }
    }

    fun onCustomAuth() {
        val authType = _uiState.value.authType
        updateAuthResult(CustomResult.InProgress)
        scope.launch {
            try {
                if (authType == AuthType.SIGN_UP) {
                    authRepository.signUp(_uiState.value.authState)
                }
                authRepository.signIn(_uiState.value.authState)
                updateAuthResult(CustomResult.Success)
            } catch (e: Exception) {
                updateAuthResult(CustomResult.DynamicError(e.toStringIfMessageIsNull()))
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

changeAuthType changes authentication type using XOR logical operator. For example, when a user is viewing a sign in composable, authType ordinal has a value of 0. Upon calling the function, 0 xor 1 will be equal to 1, selecting the auth type at index 1, which is sign up. And vise versa, when a user is viewing a sign up composable and calls changeAuthType function, the result of 1 xor 1 will be 0.
onCustomAuth function starts off by telling a user that the authentication is running, then launches a coroutine scope with a try catch block. If a user currently wants to sign up, a corresponding repository function is executed. After successful sign up, an automatic sign in attempt is performed. If auth is successful, the function updates the result to Success.


UI code

(Optional)
Let's create a fancy title for our app with gradient animation

@Composable
fun Title() {
    val gradientColors = listOf(MaterialTheme.colorScheme.onBackground,
        MaterialTheme.colorScheme.primary,
        MaterialTheme.colorScheme.onPrimary.copy(0.5f))
    var offsetX by remember { mutableStateOf(0f) }
    LaunchedEffect(Unit) {
        animate(
            initialValue = 0f,
            targetValue = 1000f,
            animationSpec = tween(3000)) {value, _ ->
            offsetX = value
        }
    }
    val brush = Brush.linearGradient(
        colors = gradientColors,
        start = Offset(offsetX, 0f),
        end = Offset(offsetX + 200f, 100f)
    )
    Text(stringResource(id = R.string.app_name),
        style = MaterialTheme.typography.titleLarge.copy(brush = brush))
}
Enter fullscreen mode Exit fullscreen mode

Let's define an auth field

@Composable
fun CustomInputField(
    enabled: Boolean,
    fieldType: FieldType,
    value: String?,
    error: String,
    onValueChange: (String) -> Unit) {
    val shape = RoundedCornerShape(dimensionResource(id = R.dimen.auth_corner))
    val fieldTypeString = stringResource(id = when(fieldType) {
        FieldType.USERNAME -> R.string.username
        FieldType.EMAIL -> R.string.email
        FieldType.PASSWORD -> R.string.password
    })
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.fillMaxWidth()
    ) {
        OutlinedTextField(
            value = value ?: "",
            isError = error.isNotEmpty(),
            onValueChange = onValueChange,
            enabled = enabled,
            shape = shape,
            keyboardOptions = KeyboardOptions(imeAction =
            if (fieldType == FieldType.USERNAME || fieldType == FieldType.EMAIL) ImeAction.Next else ImeAction.Done),
            placeholder = {
                if (value.isNullOrBlank())
                    Text(fieldTypeString)
            },
            singleLine = true,
            modifier = Modifier.fillMaxWidth().testTag(fieldTypeString)
        )
        if (error.isNotEmpty()) {
            Text(error,
                textAlign = TextAlign.Center,
                color = MaterialTheme.colorScheme.error,
                modifier = Modifier.testTag(error))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, we use fieldType to decide the Ime action and show a placeholder. Also, we use the result of input validators to either show or not show an error text composable


Let's define an auth button

@Composable
fun CustomAuthButton(
    authType: AuthType,
    enabled: Boolean,
    onClick: () -> Unit) {
    val label = stringResource(
        id = when (authType) {
            AuthType.SIGN_IN -> R.string.sign_in
            AuthType.SIGN_UP -> R.string.sign_up
        }
    )
    ElevatedButton(onClick = onClick,
        shape = RoundedCornerShape(dimensionResource(id = R.dimen.button_corner)),
        colors = ButtonDefaults.buttonColors(),
        enabled = enabled,
        modifier = Modifier.fillMaxWidth()
    ) {
        Text(label, style = MaterialTheme.typography.displaySmall,
            modifier = Modifier.padding(10.dp))
    }
}
Enter fullscreen mode Exit fullscreen mode

Here authType comes in handy, as we can show an appropriate label for a button.


After that follows a "go to text" which would enable a user to transition between desired auth states

@Composable
fun GoToText(authType: AuthType,
             enabled: Boolean,
             onClick: () -> Unit) {
    val label = stringResource(
        id = if (authType == AuthType.SIGN_IN) R.string.go_to_signup else R.string.go_to_signin
    )
    TextButton(onClick = onClick,
        enabled = enabled) {
        Text(label, style = MaterialTheme.typography.labelSmall.copy(
            textDecoration = TextDecoration.Underline
        ))
    }
}
Enter fullscreen mode Exit fullscreen mode

Here we show "Go to sign up" text if a user is in sign in state. And vise versa.


Now we combine all the composables above into a single card

@Composable
fun AuthFieldsColumn(
    uiState: AuthViewModel.UiState,
    authEnabled: Boolean,
    onUsername: (String) -> Unit,
    onEmail: (String) -> Unit,
    onPassword: (String) -> Unit,
    onChangeAuthType: () -> Unit,
    onAuth: () -> Unit
) {
    val shape = RoundedCornerShape(dimensionResource(id = R.dimen.auth_corner))
    val validationState = uiState.validationState
    val authState = uiState.authState
    val authResult = uiState.authResult
    val context = LocalContext.current
    val usernameValidationError = validationState.usernameValidationError.asString(context)
    val emailValidationError = validationState.emailValidationError.asString(context)
    val passwordValidationError = validationState.passwordValidationError.asString(context)
    val authButtonEnabled = (usernameValidationError.isEmpty() && authState.username != null || uiState.authType != AuthType.SIGN_UP) &&
            (emailValidationError.isEmpty() && authState.email != null) &&
            (passwordValidationError.isEmpty() && authState.password != null) && authEnabled
    ElevatedCard(
        modifier = Modifier
            .width(350.dp)
            .shadow(
                dimensionResource(id = R.dimen.shadow_elevation),
                shape = shape,
                spotColor = MaterialTheme.colorScheme.primary
            )
            .clip(shape)
            .background(MaterialTheme.colorScheme.onBackground.copy(0.1f))
            .border(1.dp, MaterialTheme.colorScheme.primary, shape)
    ) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(20.dp),
            modifier = Modifier
                .fillMaxWidth()
                .padding(10.dp)
        ) {
            AnimatedVisibility(uiState.authType == AuthType.SIGN_UP) {
                CustomInputField(
                    fieldType = FieldType.USERNAME,
                    enabled = authEnabled,
                    value = authState.username,
                    error = usernameValidationError,
                    onValueChange = onUsername)
            }
            CustomInputField(
                fieldType = FieldType.EMAIL,
                enabled = authEnabled,
                value = authState.email,
                error = emailValidationError,
                onValueChange = onEmail)
            CustomInputField(
                fieldType = FieldType.PASSWORD,
                enabled = authEnabled,
                value = authState.password,
                error = passwordValidationError,
                onValueChange = onPassword)
            CustomAuthButton(
                authType = uiState.authType,
                enabled = authButtonEnabled,
                onClick = onAuth)
            if (authResult is CustomResult.InProgress) {
                LinearProgressIndicator()
            }
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                if (uiState.authType == AuthType.SIGN_IN) {
                    Text(stringResource(id = R.string.dont_have_an_account),
                        style = MaterialTheme.typography.labelSmall)
                }
                GoToText(authType = uiState.authType,
                    enabled = authEnabled,
                    onClick = onChangeAuthType)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In the code above authButtonEnabled will be true in 2 cases: either if a current auth option is sign in and email with password pass the input check, or if a current option is sign up and username together with email and password pass the check. Also, username text field will be shown only on sign up.


After that I've defined another composable that accepts ui state and callback methods. This is useful for previewing composables.

@Composable
fun AuthContentColumn(
    uiState: AuthViewModel.UiState,
    onUsername: (String) -> Unit,
    onEmail: (String) -> Unit,
    onPassword: (String) -> Unit,
    onChangeAuthType: () -> Unit,
    onCustomAuth: () -> Unit,
    onSignGoogleSignIn: suspend () -> IntentSender?,
    onSignInWithIntent: (ActivityResult) -> Unit) {
    val authResult = uiState.authResult
    val authEnabled = authResult !is CustomResult.InProgress && authResult !is CustomResult.Success
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(40.dp),
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .padding(10.dp)
    ) {
        Title()
        AuthFieldsColumn(
            uiState = uiState,
            authEnabled = authEnabled,
            onUsername = onUsername,
            onEmail = onEmail,
            onPassword = onPassword,
            onChangeAuthType = onChangeAuthType,
            onAuth = onCustomAuth)
    }
}
Enter fullscreen mode Exit fullscreen mode

Next create a top auth screen composable

@Composable
fun AuthScreen(
    onSignIn: () -> Unit,
    viewModel: AuthViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsState()
    val focusManger = LocalFocusManager.current
    val snackbarController = LocalSnackbarController.current
    LaunchedEffect(uiState.authResult) {
        if (uiState.authResult is CustomResult.Success) {
            focusManger.clearFocus(true)
            onSignIn()
        }
    }
    LaunchedEffect(uiState.authResult) {
        snackbarController.showErrorSnackbar(uiState.authResult)
    }
    AuthContentColumn(
        uiState = uiState,
        onUsername = viewModel::onUsername,
        onEmail = viewModel::onEmail,
        onPassword = viewModel::onPassword,
        onChangeAuthType = viewModel::changeAuthType,
        onCustomAuth = viewModel::onCustomAuth,
        onSignGoogleSignIn = viewModel::onGoogleSignIn,
        onSignInWithIntent = viewModel::onSignInWithIntent,
    )
}
Enter fullscreen mode Exit fullscreen mode

On successful authentication, the composable clears keyboard focus and calls onSignIn callback, which is usually responsible for navigating to another screen. On unsuccessful authentication, an error snackbar is shown. Here's the code for it:

In MainActivity.kt create a LocalSnackbarController variable

val LocalSnackbarController = compositionLocalOf<SnackbarController> {
    error("No snackbar host state provided")
}
Enter fullscreen mode Exit fullscreen mode

Then inside of setContent function wrap the app's content with CompositionLocalProvider and define a dismissable snackbar. To learn more about CompositionLocalProvider, visit this page

setContent {
    val snackbarHostState = remember {
        SnackbarHostState()
    }
    val swipeToDismissBoxState = rememberSwipeToDismissBoxState(confirmValueChange = {value ->
        if (value != SwipeToDismissBoxValue.Settled) {
            snackbarHostState.currentSnackbarData?.dismiss()
            true
        } else false
    })
    val snackbarController by remember(snackbarHostState) {
        mutableStateOf(SnackbarController(snackbarHostState, lifecycleScope, applicationContext))
    }
    LaunchedEffect(swipeToDismissBoxState.currentValue) {
        if (swipeToDismissBoxState.currentValue != SwipeToDismissBoxValue.Settled) {
            swipeToDismissBoxState.reset()
        }
    }
    MoneyMonocleTheme(darkTheme = isThemeDark.value) {
        CompositionLocalProvider(LocalSnackbarController provides snackbarController) {
            Surface(
                Modifier
                    .fillMaxSize()
                    .background(MaterialTheme.colorScheme.background)
            ) {
                Box(
                    Modifier
                        .fillMaxSize()
                        .imePadding()
                ) {
                    MoneyMonocleNavHost(
                        navController = navController
                    )
                    CustomErrorSnackbar(snackbarHostState = snackbarHostState,
                        swipeToDismissBoxState = swipeToDismissBoxState)
                }
            }
        }

    }
}
Enter fullscreen mode Exit fullscreen mode

After that in a separate file create SnackbarController with CustomErrorSnackbar

class SnackbarController(
    private val snackbarHostState: SnackbarHostState,
    private val coroutineScope: CoroutineScope,
    private val context: Context,
) {
    fun showErrorSnackbar(result: CustomResult) {
        if (result is CustomResult.DynamicError || result is CustomResult.ResourceError) {
            coroutineScope.launch {
                snackbarHostState.currentSnackbarData?.dismiss()
                snackbarHostState.showSnackbar(result.error.asString(context))
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomErrorSnackbar(snackbarHostState: SnackbarHostState,
                        swipeToDismissBoxState: SwipeToDismissBoxState) {
    SwipeToDismissBox(state = swipeToDismissBoxState, backgroundContent = {}) {
        SnackbarHost(hostState = snackbarHostState,
                    snackbar = {data ->
                Snackbar(
                    containerColor = MaterialTheme.colorScheme.errorContainer,
                    modifier = Modifier
                        .padding(20.dp)
                ) {
                    Row(
                        verticalAlignment = Alignment.CenterVertically,
                        modifier = Modifier.fillMaxWidth()
                    ) {
                        Text(data.visuals.message,
                            color = MaterialTheme.colorScheme.onErrorContainer,
                            modifier = Modifier.testTag(stringResource(id = R.string.error_snackbar)))
                    }
                }
            })
        }
}
Enter fullscreen mode Exit fullscreen mode

And that's all there is to it! Good luck on your Android Development journey.

Top comments (0)