Banking and wallet apps show how much care security needs. This project builds the client side of a banking-style app: sign in, keep the token encrypted, unlock with a fingerprint or face, show transactions and send a transfer. It is a learning project. It talks to a backend that you must provide, and it should never be used for real money. Real banking software needs a regulated backend, audits and much more.
The key lesson is the split of responsibility. The app is not trusted: anyone can modify it. So it only helps the user and validates early for good feedback, while the server enforces every rule again: who the user is, what the balance is and whether a transfer is allowed.
Concepts in this project
- Token, not password: after login the server returns a short-lived token. The app stores only the token, never the password.
- Android Keystore: an AES key is created inside protected storage.
TokenVaultencrypts the token with it and saves only the encrypted bytes and the random IV. - BiometricPrompt: asks for fingerprint, face or device credentials, and only reports success or failure. The app never sees biometric data.
- Interceptor:
BearerInterceptoradds the token to each request, so screens never handle it. - Validation on both sides:
BankRules.canTransferstops obviously wrong transfers early, but the server decides. - FLAG_SECURE: hides the screen from screenshots and the recent apps view.
Create the project
- New Empty Activity project
Bank, packagecom.example.bank. This project needsFragmentActivity, which the code uses. - Add the biometric, Retrofit, OkHttp and serialization libraries from the Project 18 block.
- Build or use a test backend that provides
/login,/transactionsand/transfer, and set its address inMainActivity. - Create the files below.
`Gradle dependencies for projects 11 to 21 (use the block for this project)`
// ---- Project 11 Calculator: no extra libraries. Test dependency:
testImplementation("junit:junit:4.13.2")
// ---- Project 12 E-commerce
plugins { id("com.google.devtools.ksp"); kotlin("plugin.serialization") }
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("io.coil-kt:coil-compose:2.7.0")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
// ---- Project 13 Chat (Firebase)
// project-level plugins: id("com.google.gms.google-services")
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-auth")
implementation("com.google.firebase:firebase-firestore")
implementation("com.google.firebase:firebase-messaging")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0")
// Also put google-services.json (from your Firebase console) in the app folder.
// ---- Project 14 Fitness
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// No library for the sensor: it is part of Android.
// ---- Project 15 Food delivery
implementation("com.google.maps.android:maps-compose:6.2.1")
implementation("com.google.android.gms:play-services-maps:19.0.0")
// Manifest, inside <application>:
// <meta-data android:name="com.google.android.geo.API_KEY" android:value="YOUR_KEY"/>
// Keep the key out of public git: read it from local.properties with the Secrets Gradle Plugin.
// ---- Project 16 Music player
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-session:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")
// ---- Project 17 QR scanner
implementation("androidx.camera:camera-core:1.4.1")
implementation("androidx.camera:camera-camera2:1.4.1")
implementation("androidx.camera:camera-lifecycle:1.4.1")
implementation("androidx.camera:camera-view:1.4.1")
implementation("com.google.mlkit:barcode-scanning:17.3.0")
// ---- Project 18 Banking
implementation("androidx.biometric:biometric:1.1.0")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// ---- Project 19 AI chatbot
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
// ---- Project 20 Video streaming
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-exoplayer-hls:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")
// ---- Project 21 Job portal
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
Bank/app/src/main/java/com/example/bank/
TokenVault.kt Keystore encryption of the token
BankModels.kt data classes + BankRules + money format
BankApi.kt Retrofit interface + Bearer interceptor
BankViewModel.kt login, refresh, transfer, logout
BankScreen.kt login, locked, home
MainActivity.kt FragmentActivity, biometric prompt
app/src/test/.../BankTest.kt
The code
`app/src/main/java/com/example/bank/TokenVault.kt`
package com.example.bank
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
// Keeps the login token encrypted. The AES key lives in the Android Keystore and cannot be read out.
class TokenVault(context: Context) {
private val prefs = context.getSharedPreferences("vault", Context.MODE_PRIVATE)
private val alias = "bank_token_key"
private fun key(): SecretKey {
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(store.getKey(alias, null) as? SecretKey)?.let { return it }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
generator.init(
KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build(),
)
return generator.generateKey()
}
fun save(token: String) {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key())
val encrypted = cipher.doFinal(token.toByteArray(Charsets.UTF_8))
prefs.edit()
.putString("iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
.putString("data", Base64.encodeToString(encrypted, Base64.NO_WRAP))
.apply()
}
fun read(): String? {
val iv = prefs.getString("iv", null) ?: return null
val data = prefs.getString("data", null) ?: return null
return try {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, Base64.decode(iv, Base64.NO_WRAP)))
String(cipher.doFinal(Base64.decode(data, Base64.NO_WRAP)), Charsets.UTF_8)
} catch (e: Exception) {
null
}
}
fun clear() {
prefs.edit().clear().apply()
}
}
`app/src/main/java/com/example/bank/BankModels.kt`
package com.example.bank
import kotlinx.serialization.Serializable
@Serializable
data class LoginBody(val username: String, val password: String)
@Serializable
data class LoginReply(val token: String)
@Serializable
data class Transaction(
val id: String,
val title: String,
val amountCents: Long, // negative = money out
val date: String,
)
// Pure rules, easy to test.
object BankRules {
fun balance(list: List<Transaction>): Long = list.sumOf { it.amountCents }
fun canTransfer(balanceCents: Long, amountCents: Long): Boolean {
return amountCents > 0 && amountCents <= balanceCents
}
fun mask(account: String): String {
if (account.length <= 4) return account
return "•".repeat(account.length - 4) + account.takeLast(4)
}
}
fun Long.asMoney(): String {
val sign = if (this < 0) "-" else ""
val abs = kotlin.math.abs(this)
return sign + (abs / 100) + "." + (abs % 100).toString().padStart(2, '0')
}
`app/src/main/java/com/example/bank/BankApi.kt`
package com.example.bank
import okhttp3.Interceptor
import okhttp3.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
// This is the contract your own bank-style backend must provide.
// Never ship real banking features without a server that enforces every rule.
interface BankApi {
@POST("login")
suspend fun login(@Body body: LoginBody): LoginReply
@GET("transactions")
suspend fun transactions(): List<Transaction>
@POST("transfer")
suspend fun transfer(@Body body: TransferBody)
}
@kotlinx.serialization.Serializable
data class TransferBody(val toAccount: String, val amountCents: Long)
class BearerInterceptor(private val vault: TokenVault) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = vault.read()
val builder = chain.request().newBuilder()
if (token != null) builder.header("Authorization", "Bearer " + token)
return chain.proceed(builder.build())
}
}
`app/src/main/java/com/example/bank/BankViewModel.kt`
package com.example.bank
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class BankState(
val loggedIn: Boolean = false,
val unlocked: Boolean = false, // passed the biometric check this session
val loading: Boolean = false,
val error: String? = null,
val transactions: List<Transaction> = emptyList(),
val message: String? = null,
)
class BankViewModel(
private val api: BankApi,
private val vault: TokenVault,
) : ViewModel() {
private val _state = MutableStateFlow(BankState(loggedIn = vault.read() != null))
val state: StateFlow<BankState> = _state.asStateFlow()
fun login(user: String, password: String) {
if (user.isBlank() || password.length < 8) {
_state.update { it.copy(error = "Enter your user name and a password of 8 or more characters") }
return
}
_state.update { it.copy(loading = true, error = null) }
viewModelScope.launch {
try {
val reply = api.login(LoginBody(user.trim(), password))
vault.save(reply.token)
_state.update { it.copy(loggedIn = true, unlocked = true, loading = false) }
refresh()
} catch (e: Exception) {
_state.update { it.copy(loading = false, error = "Sign in failed. Check your details and connection.") }
}
}
}
fun unlocked() {
_state.update { it.copy(unlocked = true) }
refresh()
}
fun refresh() {
viewModelScope.launch {
try {
val list = api.transactions()
_state.update { it.copy(transactions = list, error = null) }
} catch (e: Exception) {
_state.update { it.copy(error = "Could not load transactions") }
}
}
}
fun transfer(to: String, amountText: String) {
val cents = parse(amountText)
val balance = BankRules.balance(_state.value.transactions)
if (cents == null || !BankRules.canTransfer(balance, cents)) {
_state.update { it.copy(message = "Enter an amount up to your balance") }
return
}
viewModelScope.launch {
try {
api.transfer(TransferBody(to, cents)) // the server checks everything again
_state.update { it.copy(message = "Transfer sent") }
refresh()
} catch (e: Exception) {
_state.update { it.copy(message = "Transfer failed") }
}
}
}
fun logout() {
vault.clear()
_state.value = BankState()
}
private fun parse(text: String): Long? {
val m = Regex("^(\\d+)(\\.(\\d{1,2}))?$").matchEntire(text.trim()) ?: return null
val whole = m.groupValues[1].toLong()
val frac = m.groupValues[3].padEnd(2, '0').ifEmpty { "0" }.toLong()
return whole * 100 + frac
}
}
`app/src/main/java/com/example/bank/BankScreen.kt`
package com.example.bank
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
@Composable
fun BankApp(vm: BankViewModel, askBiometric: (onOk: () -> Unit) -> Unit) {
val s by vm.state.collectAsState()
when {
!s.loggedIn -> Login(vm, s)
!s.unlocked -> Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.Center) {
Text("Locked", style = MaterialTheme.typography.headlineMedium)
Button(onClick = { askBiometric(vm::unlocked) }) { Text("Unlock with fingerprint or face") }
TextButton(onClick = vm::logout) { Text("Sign out") }
}
else -> Home(vm, s)
}
}
@Composable
private fun Login(vm: BankViewModel, s: BankState) {
var user by remember { mutableStateOf("") }
var pass by remember { mutableStateOf("") }
Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.Center) {
Text("Sign in", style = MaterialTheme.typography.headlineMedium)
OutlinedTextField(user, { user = it }, label = { Text("User name") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(
pass, { pass = it }, label = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
s.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Button(onClick = { vm.login(user, pass) }, enabled = !s.loading, modifier = Modifier.padding(top = 12.dp)) {
Text(if (s.loading) "Signing in..." else "Sign in")
}
}
}
@Composable
private fun Home(vm: BankViewModel, s: BankState) {
var to by remember { mutableStateOf("") }
var amount by remember { mutableStateOf("") }
val balance = BankRules.balance(s.transactions)
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Balance", style = MaterialTheme.typography.titleMedium)
TextButton(onClick = vm::logout) { Text("Sign out") }
}
Text(balance.asMoney(), style = MaterialTheme.typography.displaySmall)
OutlinedTextField(to, { to = it }, label = { Text("To account") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(amount, { amount = it }, label = { Text("Amount") }, modifier = Modifier.fillMaxWidth())
Button(onClick = { vm.transfer(to, amount) }) { Text("Send") }
s.message?.let { Text(it) }
s.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Text("Recent transactions", style = MaterialTheme.typography.titleMedium)
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
items(s.transactions, key = { it.id }) { t ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
Text(t.title)
Text(t.date, style = MaterialTheme.typography.bodySmall)
}
Text(t.amountCents.asMoney())
}
}
}
}
}
`app/src/main/java/com/example/bank/MainActivity.kt`
package com.example.bank
import android.os.Bundle
import android.view.WindowManager
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
// BiometricPrompt needs a FragmentActivity, so this one extends FragmentActivity.
class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// hide the screen content in the recent apps list and block screenshots
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
val vault = TokenVault(applicationContext)
val client = OkHttpClient.Builder().addInterceptor(BearerInterceptor(vault)).build()
val api = Retrofit.Builder()
.baseUrl("https://your-bank-backend.example.com/") // replace with your own server
.client(client)
.addConverterFactory(Json { ignoreUnknownKeys = true }.asConverterFactory("application/json".toMediaType()))
.build()
.create(BankApi::class.java)
setContent {
MaterialTheme {
val vm: BankViewModel = viewModel(
factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return BankViewModel(api, vault) as T
}
},
)
BankApp(vm, askBiometric = { onOk -> authenticate(onOk) })
}
}
}
private fun authenticate(onOk: () -> Unit) {
val allowed = BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL
if (BiometricManager.from(this).canAuthenticate(allowed) != BiometricManager.BIOMETRIC_SUCCESS) {
return // a real app would explain how to set up a screen lock
}
val prompt = BiometricPrompt(
this,
ContextCompat.getMainExecutor(this),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onOk()
}
},
)
val info = BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock your account")
.setAllowedAuthenticators(allowed)
.build()
prompt.authenticate(info)
}
}
`app/src/test/java/com/example/bank/BankTest.kt`
package com.example.bank
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BankTest {
private val list = listOf(
Transaction("1", "Salary", 500000, "2026-11-01"),
Transaction("2", "Rent", -150000, "2026-11-02"),
)
@Test
fun balanceAddsAmounts() {
assertEquals(350000L, BankRules.balance(list))
}
@Test
fun cannotSendMoreThanBalance() {
assertTrue(BankRules.canTransfer(350000, 100))
assertFalse(BankRules.canTransfer(350000, 400000))
assertFalse(BankRules.canTransfer(350000, 0))
}
@Test
fun accountIsMasked() {
assertEquals("••••5678", BankRules.mask("12345678"))
}
@Test
fun negativeMoneyFormat() {
assertEquals("-1500.00", (-150000L).asMoney())
}
}
How it works
- At start, the app checks whether an encrypted token exists. If it does, the screen is locked until the user passes the biometric prompt. If not, the login screen shows.
- Login validates the fields, calls the API, saves the token through
TokenVaultand loads the transactions. - The balance is the sum of the transactions in cents, formatted with two decimals. Money out is negative.
- A transfer is checked with
BankRules.canTransfer, sent to the server, and the list is refreshed. Server errors show a generic message that reveals no details. - Logout clears the vault and the state.
Expected output
With a working test backend: the first screen asks for a user name and password. A short password shows "Enter your user name and a password of 8 or more characters". After a successful sign in you see your balance, a transfer form and the recent transactions. Close the app and reopen it: the screen is locked, and the unlock button opens the system fingerprint or face dialog. After a successful check you see your account again. Entering an amount above your balance shows "Enter an amount up to your balance". Screenshots of the app are blocked by the system. Without a backend, sign in shows a failure message and nothing crashes.
Your task
- Build the app against a small test backend of your own.
- Add auto-lock after one minute in the background using the lifecycle.
- Add a Retrofit interceptor that logs out when the server answers 401.
- Review the app against the security checklist in lesson 25 and write down three improvements.
How to check it worked
- The token is never stored in plain text.
- Biometric success is required after restarting the app.
- The server, not the app, enforces transfer limits.