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
- On your laptop, navigate to
YOUR*TUTORIALS/ - Unzip your
chatter.zipfile. Double check that you still have a copy of the zipped file for future reference! - Rename your newly unzipped
chatterfoldersignin - Remove your
signin’s.gradledirectory by running in a shell window:laptop$ cd YOUR*TUTORIALS/signin/composeChatter laptop$ rm -rf .gradle - Push your local
YOUR*TUTORIALS/repo to GitHub and make sure there’re no git issues:<summary>git push</summary>- Open GitHub Desktop and click on
Current Repositoryon the top left of the interface - Click on your assignment GitHub repo
- Add Summary to your changes and click
Commit to main - If you have pushed other changes to your Git repo, click
Pull Originto synch up the clone on your laptop - Finally click on
Push Originto push changes to GitHub
- Open GitHub Desktop and click on
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.
- Click the big blue
Create an OAuth client IDbutton - On the
Configure a project for Google Sign-In, enter your project name, for example,composeChatter. - Click
Nextand when prompted toConfigure your OAuth cliententer your project name yet again. - The next step is “Where are you calling from?”. Choose
Android. - This will prompt for a
Package name. Open your app build file and search forapplicationID, for example:defaultConfig { applicationId "edu.umich.YOUR_UNIQNAME.composeChatter" // . . . }where
YOUR_UNIQNAMEwill be your actual uniqname. Copy and paste yourapplicationID. TheapplicationIDof your Android Studio project must match EXACTLY the package name used in the Google API Console, including any capitalization in the package name. -
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 androidWindows on PowerShell:
PS laptop> keytool -v -list -keystore ~\.android\debug.keystore -alias androiddebugkey -storepass android -keypass androidDon't have keytool or Java Runtime?
On macOS, if
keytoolcomplains that operation couldn’t be completed because it is unable to locate a Java Runtime, download and install it fromwww.java.com.To install
keytoolon Windows natively, you can follow the instructions on “How to fixkeytoolis 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
keytoolto/mnt/c/users/YOUR_WINDOWS_USERNAME/.android/debug.keystore.If you’re running Linux and don’t have
keytoolinstalled, you can install it with [thanks to D. Wegsman F21]:laptop$ sudo apt install openjdk-17-jre-headlessYou should see amongst the output, three lines that start like this:
Certificate fingerprints: SHA1: XX:XX:XX:...:XX SHA256: YY:YY:YY:...:YYCut and paste the SHA1 certificate to the Google API Console. Click
CREATE. - Copy your
Client ID(ofWeb client) to the clipboard and add it to your/app/res/values/strings.xml, replacingYOUR_WEB_CLIENT_IDbelow ith your actual client ID:<resources> ... <string name="clientID">YOUR_WEB_CLIENT_ID</string> </resources>The
credentials.jsonyou 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 withWeb client (Auto-created for Google Sign-in), not withOAuth 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:
- to use Google Sign-In to obtain a
chatterID, - to use Android
SharedPreferencesto store encryptedchatterID, where the encryption/decryption process performed byAndroid KeyStoreand 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,
GetSignInWithGoogleOptionrequires 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,GetSignInWithGoogleOptionwill 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:
-
setFilterByAuthorizedAccounts(false): If no authorized Google Accounts has previously been used to sign in to your app on this device/emulator, the user will be prompted to sign up with any accounts currently signed in on device/simulator. If no account is signed in on device/emulator,GetGoogleIdOptioncausesGetCredentialRequestto fail with “Found no credentials” error message. -
setServerClientId: theclientIDargument isYOUR_WEB_CLIENT_IDthat you’ve saved to/app/res/values/strings.xmlabove. It allows theCredentialManagerto authenticate you with Google. -
setAutoSelectEnabled:truemeans if you have only one account to sign in to,CredentialManagerwill sign you in automatically, without you having to click the account. - (Optional)
setNonce(): to prevent your request being maliciously replayed. It had no effect on me: I was able to make multiple credential requests, back to back, using the same nonce.
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:
- replace:
"username" to chatt.username,with:
"chatterID" to id, - change
postchattin theapiUrldeclaration topostauth
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
Unlike in previous labs, there are a CRUCIAL couple of extra steps to do before you push your lab to GitHub:
- Copy
debug.keystorein (~/.android/on Mac Terminal and Windows PowerShell) to yoursigninlab folder. - Put a copy of the
SHA1 certificate(in the format ofxx:xx:xx:...) you used to obtain your Client ID in theREADME.mdfile at your repo’s top level folder.
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:
- Open GitHub Desktop and click on
Current Repositoryon the top left of the interface - Click on the GitHub repo you created at the start of this tutorial
- Add Summary to your changes and click
Commit to mainat the bottom of the left pane - If you have pushed code to your repo, click
Pull Originto synch up the repo on your laptop - Finally click
Push Originto push all changes to GitHub
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
- Add Google Sign-in to Your Android App
- GoogleSignIn.silentSignIn
- The overall Google Identity Platform
- JWT (JSON Web Token) (in)security
- Object (Singleton)
- Instant Android instantaneous point on the time-line.
Android Keystore and Credential Manager
SharedPreferences
- SharedPreferences is your answer to simple storage
- Shared Preferences
- getSharedPreferences
- Shared Preferences Editor
- EncryptedSharedPreferences isUserAuthenticationRequired not working properly
Biometric Authentication
- Using BiometricPrompt with CryptoObject: how and why
- Show a biometric authentication dialog
- What is the difference between CBC and GCM mode?
- What exactly does the “NoPadding” parameter do in the Cipher class?
- Class3BiometricOrCredentialAuthPrompt.authenticate
- BiometricPrompt in android is not supporting exit on back button
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 |