Tutorial: Signin Compose

Cover Page

DUE Wed, 11/19, 2 pm

This tutorial may be completed individually or in teams of at most 2. You can partner differently for each tutorial.

Preparing your GitHub repo

:point_right: Go to the GitHub website to confirm that your folders follow this structure outline:

  reactive
    |-- chatter.zip
    |-- chatterd
    |-- chatterd.crt
    |-- llmprompt.zip
    |-- signin
        |-- composeChatter
            |-- app
            |-- gradle
    # and other files or folders

If the folders in your GitHub repo does not have the above structure, we will not be able to grade your assignment and you will get a ZERO.

Creating Google OAuth Client

The following instructions are largely based on Authenticate users with Sign in with Google and Set up your Google APIs console project, simplified and elaborated upon.

Dependencies

In your app build file, add Google Credentials and GoogleID libraries as a dependency. Also add the dependency for the biometric package:

    dependencies {
        ...
        implementation("androidx.credentials:credentials:1.5.0")
        implementation("androidx.credentials:credentials-play-services-auth:1.5.0")
        implementation("com.google.android.libraries.identity.googleid:googleid:1.1.1")
        implementation("androidx.biometric:biometric:1.2.0-alpha05")
        implementation("androidx.appcompat:appcompat:1.7.1")
    }

and tap on Sync Now on the Gradle menu strip that shows up at the top of the editor screen.

Creating an OAuth client ID

Go to this link: Set up your Google APIs console project. You’ll see the big blue Create an OAuth client ID button. Click on the button. If you’re not signed in to Google, you’ll be prompted to sign in. You will need a gmail account, not your umich email address, to create an OAuth client ID. Once you’ve signed in, click the Create an OAuth client ID button again.

The link is for iOS, but it works as well for Android. The Android page is now overly complicated with manual steps, full of legalese. We’ll use the iOS page instead. You just need to get to Create an OAuth client ID button on the page, you can ignore the rest of the page.

  1. Click the big blue Create an OAuth client ID button
  2. On the Configure a project for Google Sign-In, enter your project name, for example, composeChatter.
  3. Click Next and when prompted to Configure your OAuth client enter your project name yet again.
  4. The next step is “Where are you calling from?”. Choose Android.
  5. This will prompt for a Package name. Open your app build file and search for applicationID, for example:
     defaultConfig {
         applicationId "edu.umich.YOUR_UNIQNAME.composeChatter"
         // . . .
     }
    

    where YOUR_UNIQNAME will be your actual uniqname. Copy and paste your applicationID. The applicationID of your Android Studio project must match EXACTLY the package name used in the Google API Console, including any capitalization in the package name.

  6. You also need to enter the SHA-1 signing certificate. To get your certificate, on your laptop enter:

    MacOS on Terminal:

    laptop$ keytool -v -list -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android
    

    Windows on PowerShell:

    PS laptop> keytool -v -list -keystore ~\.android\debug.keystore -alias androiddebugkey -storepass android -keypass android
    
    Don't have keytool or Java Runtime?

    On macOS, if keytool complains that operation couldn’t be completed because it is unable to locate a Java Runtime, download and install it from www.java.com.

    To install keytool on Windows natively, you can follow the instructions on “How to fix keytool is not recognized as an internal or external command” [thanks to K. Kovalchuk F21].

    If you’re using WSL as terminal but run Android Studio as a Windows application instead of a Linux application, you must point keytool to /mnt/c/users/YOUR_WINDOWS_USERNAME/.android/debug.keystore.

    If you’re running Linux and don’t have keytool installed, you can install it with [thanks to D. Wegsman F21]:

    laptop$ sudo apt install openjdk-17-jre-headless
    

    You should see amongst the output, three lines that start like this:

    Certificate fingerprints:
             SHA1: XX:XX:XX:...:XX
             SHA256: YY:YY:YY:...:YY
    

    Cut and paste the SHA1 certificate to the Google API Console. Click CREATE.

  7. Copy your Client ID (of Web client) to the clipboard and add it to your /app/res/values/strings.xml, replacing YOUR_WEB_CLIENT_ID below ith your actual client ID:
    <resources>
        ...
        <string name="clientID">YOUR_WEB_CLIENT_ID</string>
    </resources>
    

    The credentials.json you downloaded above contains your Google Web client ID. You MUST use this client ID in your app. On your Google API Console, the client ID should be the one listed with Web client (Auto-created for Google Sign-in), not with OAuth client (screenshot).

And that’s all you need to obtain a Google OAuth client ID.

Obtaining chatterID

We’ll tackle integrating Google Sign-In as two tasks:

  1. to use Google Sign-In to obtain a chatterID,
  2. to use Android SharedPreferences to store encrypted chatterID, where the encryption/decryption process performed by Android KeyStore and is biometric controlled.

In this section we work on obtaining chatterID. For the tutorial, we will re-use the files Chatt.kt from the Chatter tutorial unmodified.

ChatterID

Create a new Kotlin source file called ChatterID.kt. We store the chatterID obtained from the chatterd backend in a singleton called ChatterID:

object ChatterID {
    var creator = ""
    var expiration = Instant.EPOCH // import from java.time
    var id: String? = null
        get() {
            return if (Instant.now() >= expiration) null else field
        }
        set(newValue) {
            field = newValue
        }
    
    // keystore property

}

ChatterID.id is null when either the user has not obtained a chatterID from chatterd or the ID has expired.

SubmitButton

We start with the UI to initiate Google Sign-In so that you can obtain an ID Token to use with and test the backend.

First change the username property in your ChattViewModel in MainActivity.kt file to:

    var username = mutableStateOf(app.getString(R.string.username))

Then add a new property to ChattViewModel:

    var showOk = mutableStateOf(false)

With the change to username in ChattViewModel, update your call to ChattView in ChattScrollView to:

            ChattView(it, it.username == vm.username.value)

When the user clicks on the SubmitButton, we first check if we have a valid chatterID. If we don’t, we try to obtain one before posting the chatt. To your SubmitButton composable in MainView.kt, add the following local variable:

    val context = LocalContext.current

Replace the whole vm.viewModelScope.launch {} block in the IconButton’s onClick parameter:

                if (id == null) {
                    signin(context, vm)
                    if (id == null) { // failed to sign in
                        isSending = false
                        return@launch
                    }
                }
                postChatt(Chatt(vm.username.value,
                    mutableStateOf(vm.message.text.toString())),
                    vm.errMsg)
                if (vm.showOk.value || vm.errMsg.value.isEmpty()) { getChatts(vm.errMsg) }
                vm.message.clearText()
                isSending = false
                withContext(AndroidUiDispatcher.Main) {
                    listScroll.animateScrollToItem(chatts.size)
                }

When showing the alert dialog box, we display the title according to whether vm.showOk is true. Change the title parameter of the AlertDialog in MainView to be:

                            title = {
                                Text(
                                    if (vm.showOk.value) "Biometric Authenticated"
                                    else "Chatter Error",
                                    fontWeight = FontWeight.Bold
                                )
                            },

Then in the onClick action of the TextButton passed to the confirmButton parameter, toggle vm.showOk.value to false.

Strictly speaking this alert is not needed—arguably even undesirable. We add it for better transparency into the workings of the tutorial, to provide feedback when chatterID is retrieved or restored.

signin()

Following Google’s document, Authenticate users with Sign in with Google, we first create an instance of the CredentialManager. Then we build a GetCredentialRequest, which we pass to the getCredential() method of the CredentialManager instance we created. In building the credential request, we specify the options for credential we are requeting, namely we want the credential with SignInWithGoogleOptions. Among other things, this set of options requires YOUR_WEB_CLIENT_ID you created earlier with Google Sign-In and have saved to /app/res/values/strings.xml above. It allows the CredentialManager to authenticate you with Google.

Create a new Kotlin file, call it SignInGoogle.kt and declare the following suspending function in it:

suspend fun signin(context: Context, vm: ChattViewModel) {
    val clientID = context.getString(clientID)

    // define getChatterID()

    try {
        val googleIdCredential = CredentialManager.create(context)
            .getCredential(
                request = GetCredentialRequest.Builder()
                    .addCredentialOption(
                        GetSignInWithGoogleOption.Builder(clientID)
                            .build()
                    )
                    .build(),
                context = context,
            )
        getChatterID(googleIdCredential)
    } catch (e: GetCredentialException) {
        vm.errMsg.value =
            "Failed Google Sign-In ${e.localizedMessage}\nIs application.id in Module's build.gradle and SHA-1 as registered?"
    }
}    

The getCredential() method is a suspending function. To call it we declare signin() as suspending function and launched it from a coroutine scope earlier in SubmitButton.

On device, GetSignInWithGoogleOption requires that you’re already signed in to the Gmail account you want to use with Google Signin in your app. Otherwise you will get a “credential not found” error message. On the emulator, if there are no account signed in, GetSignInWithGoogleOption will prompt you to sign in.

GetGoogleIdOption alternative

Instead of GoogleSignInWithGoogleOption, GetGoogleIdOption provides more control over credential options:

.addCredentialOption(GetGoogleIdOption.Builder()
    .setFilterByAuthorizedAccounts(false)
    .setServerClientId(clientID)
    .setAutoSelectEnabled(true)
    .build())

We specify the following options in the credential request:

Once the user is signed in and we have obtained a credential response, we call getChatterID() to extract the Google ID Token from the credential and send it to the chatterd backend to obtain a chatterID. Add the following function inside your signin() function, before the existing try-catch block, replacing the // define getChatterID() comment:

    suspend fun getChatterID(result: GetCredentialResponse) {
        val credential = result.credential

        when (credential) {
            is CustomCredential -> {
                if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {
                    try {
                        val idTokenCredential = GoogleIdTokenCredential
                            .createFrom(credential.data)

                        if (addUser(clientID,idTokenCredential.idToken, vm.errMsg) != null) {

                            // save chatterID
 
                        } else {
                            vm.errMsg.value = "Chatter backend cannot add user. Please try again."
                        }
                    } catch (e: GoogleIdTokenParsingException) {
                        vm.errMsg.value = "Invalid GoogleIdToken credential: $e"
                    }
                } else {
                    vm.errMsg.value = "Unexpected credential type"
                }
            }

            else -> {
                // Catch any unrecognized credential type here.
                vm.errMsg.value = "Unexpected non-custom credential"
            }
        }
    }

The function getChatterID() calls addUser() to asynchronously add the user to the chatterd backend and obtain a chatterID. Later when we save the chatterID to SharedPreferences, we will add a call to the save() method here, replacing the // save chatterID comment.

That’s all the code we need for the signin() function. Now we work on the networking code.

The networking

In addition to the getchatts API, we add a new API, adduser. We also modify the postchatt API, which must now carry an authenticated credential to post a chatt. Due to this change, we rename the postchatt API, postauth.

addUser()

Add the following addUser() function to your ChattStore class. The method addUser() creates a JSON object containing (1) the app’s OAuth 2.0 Client ID you previously created with Google Sign-In and (2) the idToken user obtained when signing in. It then asynchronously sends the JSON object to the chatterd backend with an HTTP POST request.

The back-end server verifies the validity of the presented idToken with Google. If verification is successful, the backend returns a chatterID (a String). Subsequently, the backend will identify the user by this chatterID, for the lifetime of the chatterID. If the token cannot be validated for whatever reason, addUser() returns null. The backend also obtains the name registered with the ID Token and returns it along with the chatterID. We use it to align retrieved chatts right (belonging to the user) or left. When Google cannot return a registered name, we default to "Profile NA", which messes up our UI, but doesn’t otherwise impact the workings of the app.

    suspend fun addUser(clientID: String, idToken: String?, errMsg: MutableState<String>): String? {

        if (idToken == null) return null

        val jsonObj = mapOf(
            "clientID" to clientID,
            "idToken" to idToken,
        )
        val requestBody = JSONObject(jsonObj).toString()
            .toRequestBody("application/json".toMediaType())

        val apiUrl = "${serverUrl}/adduser"
        val request = Request.Builder()
            .url(apiUrl)
            .header("Content-Type", "application/json")
            .post(requestBody)
            .build()

        try {
            val response = client.newCall(request).await()
            if (response.isSuccessful) {

                // obtain username and chatterID from backend

            }
            errMsg.value = "addUser: ${response.body.string()}"
        } catch (e: IOException) {
            errMsg.value = "addUser: ${e.localizedMessage ?: "POSTing failed"}"
        }
        return null
    }

If we did not retrieve a valid chatterID from the backend, the catch block simply returns an error message and null. Otherwise, we first deserialize the backend’s response to our POST above, and if the deserialization is successful, we set the creator of the chatterID in the ChatterID singleton to the registered name returned by Google to chatterd—note that the creator name cannot be longer than 100 elements. We also store the chatterdID returned by the backend along with its computed expiration time in the ChatterID singleton. In the successful case, addUser() returns the chatterID. Replace the // obtain username and chatterID from backend comment above with:

                val responseObj = JSONObject(response.body.string())
                val creator =
                    try { responseObj.getString("username") }
                    catch (e: JSONException) { "" }
                if (creator.length > 100) {
                    errMsg.value = "addUser: creator name (${creator}) longer than 100 characters"
                    return null
                }
                ChatterID.creator = creator

                id = try { responseObj.getString("chatterID") }
                     catch (e: JSONException) { null }
                expiration = Instant.now().plusSeconds(
                    try { responseObj.getLong("lifetime") }
                    catch (e: JSONException) { 0L })
                return id

postChatt()

The postauth API, replacing the postchatt API, requires that chatterID be sent along with each chatt. It first verifies that the chatterID exists in the database and has not expired. If so, the new chatt, along with the user’s username (retrieved from the database) will be added to the chatts database. Otherwise, HTTP error 401 is returned to the client.

We need to make two changes to postChatt(_:errMsg:) in ChattStore:

  1. replace:
    "username" to chatt.username,
    

    with:

    "chatterID" to id,
    
  2. change postchatt in the apiUrl declaration to postauth

We can use the rest of postChatt() unmodified.

That’s all we need to do to obtain and use chatterID. Next we will look at how to store the chatterID in Android SharedPreferences and how to use biometric check to control access to the chatterID stored in SharedPreferences.

At this point, if you haven’t completed your backend, we suggest you switch gear and work on your backend. You’ll need the ID Token obtained from Google Sign-In to test your backend, which is why we had you work on the front end up to this point first.

The ID Token is the second argument passed to addUser() above. Put a break point in addUser() and run your front end to this break point. Or you can have Android Studio print it out for you using Log.d(), for example.

SharedPreferences with biometric access control

Returning to the front end: we store the chatterID obtained from the backend encrypted in Android SharedPreferences. We use Android Keystore to hold our keys, with key access protected by biometric authentication. The following instructions are heavily based on Using BiometricPrompt with CryptoObject: how and why. Recall that the only purpose of the biometric check is to control access to the stored chatterID across invocations of the app. It doesn’t make the sign-in process itself any more secure.

The Android KeyStore trusted execution environment

The Android KeyStore is a “trusted execution environment” (TEE), a.k.a. a “secure enclave.” It’s a piece of hardware dedicated to performing cryptographic functions and storing secret keys. It has a limited set of APIs. To use it, you ask it to create a key for you. It returns a handle/alias for the key, but not the key itself. You can give the hardware plain text to be encrypted using the key it is holding for you. And you can give it encrypted text to be decrypted using the key under your name. The key itself never leaves the hardware’s possession and you can only ask the hardware to do encryption or decryption.

Since ChatterID is not an encryption key, storing it encrypted in SharedPreferences fits our purpose.

SharedPreferences storage

Create a new Kotlin file called ChattKeyStore.kt and put the constants in it and declare the ChattKeyStore class with

private const val ID_FILE = "Chatter"
private const val KEY_NAME = "ChatterID"
private const val NUM_LENGTH = 2
private const val IV_LENGTH = 12

private const val KEY_STORE = "AndroidKeyStore"
private const val KEY_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
private const val KEY_BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM
private const val KEY_PADDING = KeyProperties.ENCRYPTION_PADDING_NONE // GCM requires no padding

class ChatterKeyStore(val authenticate: Boolean) { 
    // encryption methods

    // biometric prompt

    // retrieve chatterID

    // store chatterID

}

Add a new property to your ChatterID singleton, replacing the comment line // keystore property:

    var keystore = ChatterKeyStore(authenticate = true) // true means we're doing encryption with biometric authentication

In your MainActivity.kt, change the declaration of your MainActivity class to inherit from FragmentActivity() instead of ComponentActivity(). The AuthPrompt() interface requires the context to be FragmentActivity(). It is not (yet?) compatible with ComponentActivity() and your app will crash if you add biometric authentication without changing MainActivity to inherit from FragmentActivity().

Add the following property to your MainActivity class:

    private val viewModel: ChattViewModel by viewModels()

Then in its onCreate method, load the previous session’s chatterID from SharedPreferences by calling ChattKeyStore’s open() method. We call open() with structured concurrency, to wait until open() completes before proceeding with the rest of the code. Add the following code to your onCreate, before calling setContent{ }:

        lifecycleScope.launch {
            withContext(Dispatchers.Main.immediate) {
                keystore.open(this@MainActivity, viewModel.showOk,
                    viewModel.errMsg)
                viewModel.username.value = creator
            }
        }

Encryption methods

We will need two helper encryption methods, getKey() and createCipher(). The method getkey() asks Android KeyStore to generate a key given your specifications. The build parameter, .setUserAuthenticationParameters() enables authentication check. Replace the comment line // encryption methods in your ChattKeyStore class with the following two methods:

    private fun getKey(keyName: String): SecretKey {
        return KeyStore.getInstance(KEY_STORE)
            .apply {
                load(null)
            }.getKey(keyName, null) as? SecretKey
            ?: KeyGenerator.getInstance(KEY_ALGORITHM, KEY_STORE)
                .apply {
                    init(
                        KeyGenParameterSpec.Builder(keyName,
                            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
                            .setBlockModes(KEY_BLOCK_MODE)
                            .setEncryptionPaddings(KEY_PADDING)
                            .setKeySize(256)
                            .setUserAuthenticationRequired(authenticate)
                            .setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL)
                            .build()
                    )
                }.generateKey()
    }

Given a key, you can then create a Cipher engine. You need one cipher to perform encryption and, for each decryption operation, you’ll need a different a cipher (due to the need to feed the initialization vector (IV) into the decryptor). Add the following createCipher() function to your ChatterKeyStore class:

    fun createCipher(keyName: String, iv: ByteArray? = null): Cipher {
        return Cipher.getInstance("$KEY_ALGORITHM/$KEY_BLOCK_MODE/$KEY_PADDING")
            .apply {
                iv?.let {
                    init(Cipher.DECRYPT_MODE, getKey(keyName), GCMParameterSpec(128, it))
                } ?: run {
                    init(Cipher.ENCRYPT_MODE, getKey(keyName))
                }
            }
    }

Biometric prompt

Replace the comment line // biometric prompt in ChattKeyStore class with first the following two properties, followed by the authPromptExCatcher exception handler we will use in open() and save() functions later, and our extension adding a suspending authenticate() function to the biometric-based authentication prompt:

    private lateinit var bioAuthPrompt: Class3BiometricOrCredentialAuthPrompt
    private lateinit var authPrompt: AuthPrompt

    private val authPromptExCatcher = CoroutineExceptionHandler { _, error ->
        authPrompt.cancelAuthentication()
        Log.e("AuthPrompt exception", error.localizedMessage ?: "authentication cancelled")
    }

    // import BiometricPrompt from androidx.biometric
    private suspend fun Class3BiometricOrCredentialAuthPrompt.authenticate(host: AuthPromptHost, crypto: BiometricPrompt.CryptoObject): BiometricPrompt.AuthenticationResult? =
        suspendCancellableCoroutine { cont ->
            authPrompt = startAuthentication(host, crypto, object : AuthPromptCallback() {
                override fun onAuthenticationSucceeded(
                    activity: FragmentActivity?,
                    result: BiometricPrompt.AuthenticationResult
                ) {
                    super.onAuthenticationSucceeded(activity, result)
                    cont.resume(result, null)
                }

                override fun onAuthenticationError(activity: FragmentActivity?, @BiometricPrompt.AuthenticationError errorCode: Int, errString: CharSequence) {
                    super.onAuthenticationError(activity, errorCode, errString)
                    if (errorCode == BiometricPrompt.ERROR_USER_CANCELED) {
                        cont.resume(null, null)
                    }
                }
            })
            cont.invokeOnCancellation { authPrompt.cancelAuthentication() }
        }

The authenticate() extension converts the callback-based startAuthentication() to a suspending function by using suspendCancellableCoroutine(). In the exception handler authPromptExCatcher, we cancel the failed authentication through authPrompt which was populated by startAuthentication() in authenticate().

authenticate()

The Android Biometric SDK has a non-suspending authenticate() extension function provided by Class3BiometricOrCredentialAuthExtensionsKt. As far as I can tell, the authenticate() method provided does not give access to the AuthPrompt returned by startAuthentication() and therefore one can not cancel failed authentication. Not cancelling failed authentication causes my implementation of compose navigation to repeatedly pop the navigation stack on every failed biometric check until it exits the app. Perhaps there’s another way to prevent this, but cancelling authentication works. Besides, it gives us a chance to convert a callback-based API to a suspending API.

open()

We define open() as a suspending method of the ChatterKeyStore class. The “uninitialized expiration time” of ChatterID defaults to Unix epoch (1/1/70). The ChatterID’s expiration time being other than the default indicates that this is not the first time we call open() after the initial launch of the app. It indicates that we have already opened SharedPreferences and tried to load previous session’s chatterID, so we can just return. Replace the comment line // retrieve chatterID in ChattKeyStore class definition above with:

    suspend fun open(context: FragmentActivity, showOk: MutableState<Boolean>,
                     errMsg: MutableState<String>) {
        if (expiration != Instant.EPOCH) { // this is not first launch
            return
        }
        
        // prepare biometric prompt

        // search for chatterID

    }

We check that biometric authentication is available on device. If so, we set up the biometric prompt panel to be used for authentication. Replace // perpare biometric prompt with:

        // import BiometricManager from androidx.biometric
        val status = BiometricManager.from(context).canAuthenticate(BIOMETRIC_STRONG)
        if (status != BiometricManager.BIOMETRIC_SUCCESS) {
            errMsg.value = "ChattKeyStore.open: cannot do biometric authentication ($status)"
            return
        }

        bioAuthPrompt = Class3BiometricOrCredentialAuthPrompt.Builder("Biometric ID")
            .setSubtitle("for kotlinChatter")
            .setDescription("To open and read ChatterID")
            .setConfirmationRequired(false)
            .build()

Class3 biometric prompt allows us to perform biometric authenticated cryptographic operation. We have opted to enable fall-back to PIN or pattern in lieu of biometric authentication by using Class3BiometricOrCredentialAuthPrompt, you could choose to use Class3BiometricAuthPrompt() instead for no fall-back.

We search SharedPreferences for previously encrypted chatterID. Replace the comment, // search for chatterID with:

        context.getSharedPreferences(ID_FILE, Context.MODE_PRIVATE)
            .getString(KEY_NAME, null)?.let {
                // extract creator from stored string (item)
                val strLen = it.takeLast(NUM_LENGTH).toInt()
                var item = it.dropLast(NUM_LENGTH)
                creator = item.takeLast(strLen)
                item = item.dropLast(strLen)

                // extract expiration from item
                val expLen = item.takeLast(NUM_LENGTH).toInt()
                item = item.dropLast(NUM_LENGTH)
                val idExp = Instant.parse(item.takeLast(expLen))
                item = item.dropLast(expLen)

                // extract chatterID with biometric check
                val iv = item.takeLast(IV_LENGTH).toByteArray(ISO_8859_1)
                item = item.dropLast(IV_LENGTH)
                val idEnc = item.toByteArray(ISO_8859_1)
                val decryptor = keystore.createCipher(KEY_NAME, iv)

                // perform biometric prompt and decryption

            }

If the search is succesul, we perform biometric prompt and with the user authenticated, decrypt the stored chatterID. Replace // perform biometric prompt and decryption with the following code:

                val cryptoObject = BiometricPrompt.CryptoObject(decryptor)
                withContext(authPromptExCatcher) {
                    val authResult = bioAuthPrompt.authenticate(
                        AuthPromptHost(context),
                        cryptoObject
                    )
                    authResult?.cryptoObject?.cipher?.run {
                        id = String(doFinal(idEnc))
                        expiration = idExp
                        showOk.value = true
                        errMsg.value = "ChatterID available from last session."
                    } ?: run {
                        errMsg.value = "ChattKeyStore.open: biometric authentication failed"
                    }
                }

SharedPreferences update

Every time we obtain a new chatterID from the backend, we update the entry associated with KEY_NAME in our SharedPreferences: in the getChatterID() function of your SignInGoogle.kt, replace the comment, // save chatterID, with the following lines:

                            vm.username.value = creator
                            withContext(Dispatchers.Main.immediate) {
                                keystore.save(context, vm.showOk, vm.errMsg) 
                                if (vm.errMsg.value.isEmpty()) {
                                    vm.showOk.value = true
                                    vm.errMsg.value = "ChatterID refreshed."
                                }
                            }

save()

Back in the ChatterKeyStore.kt file, define save() as a method of the ChatterKeyStore class:

    suspend fun save(context: Context, showOk: MutableState<Boolean>, errMsg: MutableState<String>) {
        // prepare biometric prompt

        // encrypt and store chatterID
    }

We check that biometric authentication is available on device. If so, we set up the authentication prompt. Replace // perpare biometric prompt with:

        val status = BiometricManager.from(context).canAuthenticate(BIOMETRIC_STRONG)
        if (status != BiometricManager.BIOMETRIC_SUCCESS) {
            errMsg.value = "ChattKeyStore.save: cannot do biometric authentication ($status)"
            return
        }

        bioAuthPrompt = Class3BiometricOrCredentialAuthPrompt.Builder("Biometric ID")
            .setSubtitle("for kotlinChatter")
            .setDescription("To save persistent ChatterID")
            .setConfirmationRequired(false)
            .build()

Then if we have a valid chatterID, we first encrypt it. Replace // encrypt and store chatterID with:

        id?.let {
            val encryptor: Cipher by lazy { keystore.createCipher(KEY_NAME) }
            val cryptoObject = BiometricPrompt.CryptoObject(encryptor)
            withContext(authPromptExCatcher) {
                val authResult = bioAuthPrompt.authenticate(
                    AuthPromptHost(context as FragmentActivity),
                    cryptoObject
                )
                authResult?.cryptoObject?.cipher?.run {
                    val idEnc = doFinal(it.toByteArray(ISO_8859_1))
                    val expStr = expiration.toString() // INSTANT_LENGTH
                    val idVal = String(idEnc + iv, ISO_8859_1) +
                            expStr + expStr.length.toString(10) +
                            creator + creator.length.toString(10)

                    // store chatterID

                } ?: run {
                    errMsg.value = "ChattKeyStore.save: biometric authentication failed"
                }
            }
        }

Unlike open(), which we run only once upon app launch, we could be executing save() multiple times during the lifetime of the app, once every chatterID lifetime. Since we only need one instance of the encryptor cipher, we create it using the lazy() delegate.

by lazy() delegation

The lazy() delegate defers initialization of a variable until its first use and, like singleton, there will only be one instance of it and its creation is thread-safe.

`ByteArray` to `String`

Without going into the workings and interfacing of the cryptographic operations, one programmatic detail you want to be careful about is the conversion of ByteArray to String and back. Since encrypted byte array contains non-printable characters, the encoding that can preserve every byte intact is ISO-8859-1 used here (which, incidentally, is also used by Android’s Base64.encodeToString in case you were tempted to use Base64 encoding instead).

And then we store the encrypted chatterID. Replace // store chatterID with:

                    context.getSharedPreferences(ID_FILE, Context.MODE_PRIVATE)
                        .edit { putString(KEY_NAME, idVal) }
                    showOk.value = true
                    errMsg.value = "ChatterID saved."

That’s all the changes we need to make to store chatterID in the SharedPreferences! You can now test your implementation of SharedPreferences storage by closing your Chatter app after making a post, re-launching it within your chatterID lifetime (which you set in the backend), and it should allow you to post without requiring you to sign in again.

To help you test your code, you may want to add the following method to the ChatterKeyStore class:

    fun delete(context: Context) {
        val folder = File(context.getFilesDir().getParent()?.toString() + "/shared_prefs/")
        val files = folder.list()
        files?.forEach {
            context.getSharedPreferences(it.replace(".xml", ""), Context.MODE_PRIVATE)
                .edit { clear() } // clear each preference file from memory
            File(folder, it).delete()   // delete the file
        }
    }

and call it whenever you want to delete your SharedPreferences entry; for example, right before you call open() in MainActivity. You can also clear your SharedPreferences completely by long holding the app icon in your app drawer, choose App info > Storage & cache > Clear storage. Note that this will also clear your Google Signin state in addition to your SharedPreferences.

You may also want to play with shorter or longer chatterID lifetime in the backend and see whether you’re asked to login again when expected.

And with that, we’re done with this tutorial! Don’t forget to test the last version of your app with biometric authentication as you did earlier versions.

Congratulations! You’re done with the front end! (Don’t forget to work on the backend!)

Run and test to verify and debug

To use biometric check, make sure that you have enabled screen lock and you have enrolled your biometric info with your device. For the Android emulator, follow the instructions in Getting Started with Android Development.

Once you have your backend setup, run your front end against your backend—mada.eecs.umich.edu is not available for this tutorial. You will not get full credit if your front end is not set up to work with your backend!

You may need to sign out from your Google account on your device to test Google Sign-In. On your development host (not on your device), open up your browser and go to a Google property, e.g., Gmail. At the upper right corner of your browser, click on your avatar icon and tap on Manage your Google Account button on the drop-down menu. Once in your Google Account, click on the Security menu on the left. In Security, scroll down until you see Your devices card. At the bottom of the card, click on Manage your devices. Find your device and click on the 3-vertical dot menu and select Sign out.

Front-end submission guidelines

:point_right: Unlike in previous labs, there are a CRUCIAL couple of extra steps to do before you push your lab to GitHub:

Without these we won’t be able to run your app.

We will only grade files committed to the main branch. If you use multiple branches, please merge them all to the main branch for submission.

Push your front-end code to the same GitHub repo you’ve submitted your back-end code:

:point_right: Go to the GitHub website to confirm that your front-end files have been uploaded to your GitHub repo and that your repo has a folder structure outline similar to the following. If your folder structure is not as outlined, our script will not pick up your submission and, further, you may have problems getting started on latter tutorials. There could be other files or folders in your local folder not listed below, don’t delete them. As long as you have installed the course .gitignore as per the instructions in Preparing GitHub for Reactive Tutorials, only files needed for grading will be pushed to GitHub.

  reactive
    |-- README.md       # <- should contain your app's SHA1 certificate  
    |-- chatter.zip
    |-- chatterd
    |-- chatterd.crt
    |-- llmprompt.zip
    |-- signin
        |-- composeChatter
            |-- app
            |-- gradle
        |-- debug.keystore            
    # and other files or folders   

Verify that your Git repo is set up correctly: on your laptop, grab a new clone of your repo and build and run your submission to make sure that it works. You will get ZERO point if your tutorial doesn’t build, run, or open.

IMPORTANT: If you work in a team, put your team mate’s name and uniqname in your repo’s README.md (click the pencil icon at the upper right corner of the README.md box on your git repo) so that we’d know. Otherwise, we could mistakenly think that you were cheating and accidentally report you to the Honor Council, which would be a hassle to undo. You don’t need a README.md if you work by yourself.

Review your information on the Tutorial and Project Links sheet. If you’ve changed your teaming arrangement from previous tutorial’s, please update your entry. If you’re using a different GitHub repo from previous tutorial’s, invite eecsreactive@umich.edu to your new GitHub repo and update your entry.

References

Android Keystore and Credential Manager

SharedPreferences

Biometric Authentication

Appendix: imports


Prepared by Benjamin Brengman, Alexander Wu, Ollie Elmgren, Wendan Jiang, Nowrin Mohamed, Chenglin Li, Xin Jie ‘Joyce’ Liu, Yibo Pi, and Sugih Jamin Last updated: October 20th, 2025