This is the second part of a series of articles that dive into creating a new Android application from scratch using Jetpack Compose for the user interface and some other tools offered by Google, like Firebase Authentication, Crashlytics, Cloud Firestore and Hilt.

Part 1 has an overview of what you can do with this app, how it is structured and what technologies we will be covering in this series. In this second part we will look at implementing the login and sign up flows and how the ViewModel connects the business logic with the composable functions. We will also cover how to add Firebase Authentication to the project.

Authentication flow

Firebase Authentication

To implement the user authentication flow, we will use a Firebase product called Authentication, that allows you to securely authenticate users to your application. It supports authentication using passwords, phone numbers and some popular federated identity providers like Google, Facebook, Twitter, Github and more.

One of the most interesting features of Firebase Authentication is Anonymous Authentication, which allows you to create an anonymous session for a user, without having to ask for any information from them. Later it is possible to upgrade an anonymous user account by asking the user to authenticate using one of the other providers, and linking their credentials to the existing (anonymous) account.

Launching the app for the first time

Nobody likes having to create a new account before they can try out a new app. To give users the best possible experience, we want to enable them to start using the app without having to sign in first. We are going to use Anonymous Authentication to achieve this.

Anonymous Authentication can be very useful in a situation like this, where we want to enable the user to have their data protected by security rules from the beginning. As soon as the app starts, we will start an anonymous session and display the TasksScreen, allowing the user to create to-do items and edit them.

Adding an account

Once the user has used the app for a bit and has come to the conclusion that they like it, we want to give them the chance to upgrade to a full account that is linked to a sign-in they can use on other devices as well. The user can do this by navigating to the settings screen and choosing to either sign in or create an account.

On this screen, the user will have two options: login or create a new account using the email and password authentication method. At this point, the previously created anonymous account will be linked to these new credentials. This means that Firebase will never create a second account for the user.

Depending on the user's authentication state, the settings screen will show different options. If the user navigates to the settings screen while signed in, two new options are going to be available: it’s possible to sign out only, or to delete the account, which will cause all the to-do items to be deleted.

Authentication flow
Authentication flow

Implementing the Authentication flow

Adding Firebase to your project

To implement the authentication flow we just saw above, we will use Firebase Authentication. First we need to add the Firebase SDK (Software Development Kit) to the Android project. To do so, we need to add the Make It So Android application to the Make It So Firebase project in the Firebase Console. Once we are done creating the project, we will be able to download a configuration file that we’ll add to the project. This configuration file contains all the information that the Firebase SDK for Android needs to connect to the Firebase project. All these instructions are detailed in the official Firebase documentation.

After we add the SDK to the project, we can add the dependency for the Firebase Authentication library to the app/build.gradle file:

dependencies {
    // Import the BoM for the Firebase platform
    implementation platform('com.google.firebase:firebase-bom:30.3.0')

    // Declare the dependency for the Firebase Authentication library
    implementation 'com.google.firebase:firebase-auth-ktx'
}

Authentication in Compose

Before we talk about using authentication in Compose, let's remember how Firebase Authentication works with an Activity. If you look at the documentation for Android, you'll find a step-by-step guide that shows how to create an instance of FirebaseAuth that will be initialized later using the onCreate method.

private lateinit var auth: FirebaseAuth

override fun onCreate() {
    super.onCreate()
    auth = Firebase.auth
}

In our case, we will only use the Activity to create the first composable function, called MakeItSoApp. The Activity and the composable functions should know nothing about the business logic and the APIs that the ViewModels will use, so this is how the MakeItSoActivity will look like:

class MakeItSoActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent { MakeItSoApp() }
    }
}

This is the MakeItSoApp method we see above:

fun MakeItSoApp() {
    MakeItSoTheme {
        Surface(color = MaterialTheme.colors.background) {

            val appState = rememberAppState()

            Scaffold(
                [...]
                scaffoldState = appState.scaffoldState
            ) {
           [...]
            }
        }
    }
}

This is the top level composable function of the app, where we will keep a reference to the state of the application so that we can observe the changes and recompose the screens as necessary. This is also where we apply the Make It So theme (which will define which colors are used in Dark mode and Light mode) and add the Scaffold in which all screens will be shown. Scaffold is a composable that provides slots for many different components and screen elements - this is useful for defining the general structure of a screen.

Creating the View Model

To prevent the composable functions from knowing anything about the business logic, we are going to call the Firebase Authentication API methods from the ViewModels. But to do that, we first need to get the data the user entered in the TextFields of the screens, so that we can pass it as parameters to the authentication methods.

Let's see how this works in the LoginScreen. This screen has quite a few pieces of data that need to be synchronized with the ViewModel (the email and password Strings). We will wrap this information in a data class to make it easier to handle:

data class LoginUiState(
    val email: String = "",
    val password: String = ""
)

Inside the LoginViewModel we are going to create a mutableState corresponding to the state in which the screen is:

var uiState = mutableStateOf(LoginUiState())
        private set

Only the ViewModel can post new values for this mutableState. The LoginScreen will only be able to observe these new states and react to them.

@Composable
fun LoginScreen(popUpScreen: () -> Unit, viewModel: LoginViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState

    BasicToolbar(AppText.login_details)

    Column([...]) {
        EmailField(uiState.email, viewModel::onEmailChange, Modifier.fieldModifier())

        [...]
    }
}

We can see in the snippet above that we are sharing the uiState.email with the EmailField composable. Let's see what this composable function does:

@Composable
fun EmailField(value: String,  onNewValue: (String) -> Unit, modifier: Modifier = Modifier) {
    OutlinedTextField(
        singleLine = true,
        modifier = modifier,
        value = value,
        onValueChange = { onNewValue(it) },
        placeholder = { Text(stringResource(AppText.email)) },
        leadingIcon = { [...] }
    )
}

We initialize the EmailField with the value emitted by the ViewModel. Every time this field is updated by the user (by typing something), we send the new value to the onNewValue callback which is passed to the composable function as a parameter.

In the LoginScreen composable that we see above, we can see that this callback passes the newly typed email for the ViewModel (viewModel::onEmailChange), and the ViewModel then posts the new email value to the uiState:

fun onEmailChange(newValue: String) {
    uiState.value = uiState.value.copy(email = newValue)
}

Once the ViewModel emits the new state, the composable function will notice that the state has been updated and will automatically update itself through a process called recomposition. Then the new email value will be reflected in the UI (the content inside the EmailField will be updated).

This is how we make sure that both ViewModel and composable function will always have the most up-to-date uiState, and as soon as the user clicks the sign in button, the ViewModel will get the most recent values ​​for email and password and send it as a parameter to the methods of the service.

Creating the Account Service

The next step is to create the AccountService, using the service interface and implementation pattern that we saw earlier in part 1 of this series. It's the full responsibility of the ViewModel to call the service methods, based on the user's interactions with the buttons on the screen. We are going to use callbacks to get the responses from calls made to the Firebase Authentication API. This is what the interface will look like:

interface AccountService {
    fun createAnonymousAccount(onResult: (Throwable?) -> Unit)
    fun authenticate(email: String, password: String, onResult: (Throwable?) -> Unit)
    fun createAccount(email: String, password: String, onResult: (Throwable?) -> Unit)
}

We need to implement these methods in the AccountServiceImpl class. Here we can see another difference in the way we used the Authentication API before. Previously, we had to call these methods directly from the Activity, so we had a context to attach to our listeners:

auth.signInWithEmailAndPassword(email, password)
        .addOnCompleteListener(this) { task ->
            if (task.isSuccessful) {
                updateUI(auth.currentUser)
            } else {
                updateUI(null)
            }
        }

Now, we won’t call these methods from the Activity any longer, so we won’t have the auth instance already initialized, and we won’t have a context to attach to the listeners. We can get around this by using a listener that doesn't require any context. Also, note that we can use the Firebase.auth instance in the implementation of the service, without having to initialize it before.

override fun createAnonymousAccount(onResult: (Throwable?) -> Unit) {
    Firebase.auth.signInAnonymously()
        .addOnCompleteListener { onResult(it.exception) }
 }

override fun authenticate(email: String, password: String, onResult: (Throwable?) -> Unit) {
    Firebase.auth.signInWithEmailAndPassword(email, password)
        .addOnCompleteListener { onResult(it.exception) }
 }

override fun createAccount(email: String, password: String, onResult: (Throwable?) -> Unit) {
     Firebase.auth.createUserWithEmailAndPassword(email, password)
         .addOnCompleteListener { onResult(it.exception) }
}

Now we just need to call these methods from the ViewModels, passing the callback as a parameter. Here is an example of the authentication method being called to sign in an user from the LoginViewModel:

fun onSignInClick(popUpScreen: () -> Unit) {
    accountService.authenticate(email, password) { error ->
        if (error == null) {
            linkWithEmail()
            popUpScreen()
        } else onError()
    }
}

Updating the Composable Screen

Next let's analyze what happens inside the callback method. Upon returning from a successful login or account creation, we proceed to the next step, which is to get the credentials the user used to sign in to the second authentication provider, and link them to the anonymous account:

private fun linkWithEmail() {
    accountService.linkAccount(email, password) { error ->
        if (error != null) logService.logNonFatalCrash(error)
    }
}

We do that by calling a Firebase Authentication API method called linkWithCredential, that updates the anonymous account with the new credentials the user signed in with (the credentials can be the user's email address and password, or an OAuth token from a federated identity provider):

override fun linkAccount(email: String, password: String, onResult: (Throwable?) -> Unit) {
    val credential = EmailAuthProvider.getCredential(email, password)

    Firebase.auth.currentUser!!.linkWithCredential(credential)
        .addOnCompleteListener { onResult(it.exception) }
}

As soon as the anonymous account is updated with the new credentials, we call the method popUpScreen, which will simply return to the previous screen, which in this case is the SettingsScreen. Once we return to this screen, the SettingsUiState is updated with the new value for the isAnonymousAccount property.

@Composable
fun SettingsScreen(
    openLogin: () -> Unit,
    openSignUp: () -> Unit,
    restartApp: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: SettingsViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState

    Column(
        modifier = modifier
            .fillMaxWidth()
            .fillMaxHeight()
            .verticalScroll(rememberScrollState())
    ) {
        [...]

        if (uiState.isAnonymousAccount) {
            RegularCardEditor(AppText.sign_in, Modifier.card()) {
                openLogin()
            }

            RegularCardEditor(AppText.create_account, Modifier.card()) {
                openSignUp()
            }
        } else {
            SignOutCard { viewModel.onSignOutClick(restartApp) }
            DeleteMyAccountCard { viewModel.onDeleteMyAccountClick(restartApp) }
        }
    }
}

As the account we are using is no longer an anonymous account, the composable function will recompose itself and update the content shown to the user, displaying the SignOutCard and the DeleteMyAccountCard instead of the Sign In and Create Account cards. This way, the UI reflects the user's authentication state, allowing them to either sign out or delete their account should they wish to.

What’s next

In part 3 of this series, we will learn about Crashlytics, and how it can help you improve the quality of your app. We will also look into how to display messages on snackbars and some improvements we can make to avoid duplicated code.

The source code for Make it So for Android is available in this Github repository. If you’re also interested in iOS/Mac development, the same application is available for these platforms as well. The iOS/Mac version uses SwiftUI for building the User Interface, which is very similar to how we build UIs in Compose. You can find the source code in this Github repository.

If you have any questions, feel free to reach out to me on Twitter.