feat: app biometric lock

This commit is contained in:
2026-06-12 21:30:37 +02:00
parent 181cf56239
commit 4546844ea9
5 changed files with 275 additions and 22 deletions
@@ -31,12 +31,17 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import cafe.adriel.lyricist.ProvideStrings import cafe.adriel.lyricist.ProvideStrings
import com.bitcointxoko.gudariwallet.data.AppLockStore
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
import com.bitcointxoko.gudariwallet.ui.lock.BiometricLockScreen
import com.bitcointxoko.gudariwallet.util.NotificationConstants import com.bitcointxoko.gudariwallet.util.NotificationConstants
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var secretStore: EncryptedSecretStore private lateinit var secretStore: EncryptedSecretStore
private lateinit var appLockStore: AppLockStore
private val isUnlocked = mutableStateOf(false)
private val vm: WalletViewModel by viewModels { private val vm: WalletViewModel by viewModels {
WalletViewModelFactory(application) WalletViewModelFactory(application)
@@ -84,6 +89,7 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
secretStore = EncryptedSecretStore(applicationContext) secretStore = EncryptedSecretStore(applicationContext)
appLockStore = AppLockStore(applicationContext)
val syncStore = HistoricalSyncStore(applicationContext) val syncStore = HistoricalSyncStore(applicationContext)
val credentialsState = secretStore.credentials val credentialsState = secretStore.credentials
.stateIn( .stateIn(
@@ -91,6 +97,12 @@ class MainActivity : AppCompatActivity() {
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
initialValue = null initialValue = null
) )
val appLockEnabledState = appLockStore.appLockEnabled
.stateIn(
scope = lifecycleScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = true // safe default: locked
)
// Must be first — before any notification is posted // Must be first — before any notification is posted
// NotificationHelper.createChannels(applicationContext) // NotificationHelper.createChannels(applicationContext)
@@ -121,34 +133,49 @@ class MainActivity : AppCompatActivity() {
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
val credentials by credentialsState.collectAsState() val credentials by credentialsState.collectAsState()
val appLockEnabled by appLockEnabledState.collectAsState()
val isOnboarded = credentials?.isOnboarded == true val isOnboarded = credentials?.isOnboarded == true
if (isOnboarded) { when {
LaunchedEffect(Unit) { !isOnboarded -> {
WalletNotificationService.start(this@MainActivity) OnboardingScreen(
requestNotificationPermissionIfNeeded() secretStore = secretStore,
syncStore = syncStore,
appLockStore = appLockStore,
onRequestNotificationPermission = { requestNotificationPermissionIfNeeded() },
onComplete = {}
)
}
appLockEnabled && !isUnlocked.value -> {
BiometricLockScreen(
onUnlocked = { isUnlocked.value = true }
)
}
else -> {
LaunchedEffect(Unit) {
WalletNotificationService.start(this@MainActivity)
requestNotificationPermissionIfNeeded()
}
WalletScreen(
vm = vm,
nfcVm = nfcVm,
pendingPaymentHash = pendingPaymentHash,
pendingNavigateToHistory = pendingNavigateToHistory,
pendingPaymentUri = pendingPaymentUri,
lyricist = lyricist
)
} }
WalletScreen(
vm = vm,
nfcVm = nfcVm,
pendingPaymentHash = pendingPaymentHash,
pendingNavigateToHistory = pendingNavigateToHistory,
pendingPaymentUri = pendingPaymentUri,
lyricist = lyricist
)
} else {
OnboardingScreen(
secretStore = secretStore,
syncStore = syncStore,
onRequestNotificationPermission = { requestNotificationPermissionIfNeeded() },
onComplete = {}
)
} }
} }
} }
} }
} }
override fun onStop() {
super.onStop()
isUnlocked.value = false
}
// ── NFC foreground dispatch ─────────────────────────────────────────────── // ── NFC foreground dispatch ───────────────────────────────────────────────
override fun onResume() { override fun onResume() {
@@ -0,0 +1,27 @@
package com.bitcointxoko.gudariwallet.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.appLockDataStore by preferencesDataStore(name = "app_lock_prefs")
class AppLockStore(private val context: Context) {
companion object {
private val KEY_APP_LOCK_ENABLED = booleanPreferencesKey("app_lock_enabled")
}
/** Defaults to true (locked) as required. */
val appLockEnabled: Flow<Boolean> = context.appLockDataStore.data
.map { prefs -> prefs[KEY_APP_LOCK_ENABLED] ?: true }
suspend fun setAppLockEnabled(enabled: Boolean) {
context.appLockDataStore.edit { prefs ->
prefs[KEY_APP_LOCK_ENABLED] = enabled
}
}
}
@@ -0,0 +1,107 @@
package com.bitcointxoko.gudariwallet.ui.lock
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Fingerprint
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
@Composable
fun BiometricLockScreen(onUnlocked: () -> Unit) {
val context = LocalContext.current
val activity = context as FragmentActivity
LaunchedEffect(Unit) {
showBiometricPrompt(activity, onUnlocked)
}
Scaffold { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(horizontal = 32.dp)
) {
Icon(
imageVector = Icons.Filled.Fingerprint,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(24.dp))
Text(
text = "Gudari Wallet",
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(8.dp))
Text(
text = "Authenticate to continue",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(32.dp))
FilledTonalButton(
onClick = { showBiometricPrompt(activity, onUnlocked) }
) {
Icon(
imageVector = Icons.Filled.Fingerprint,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize)
)
Spacer(Modifier.width(ButtonDefaults.IconSpacing))
Text("Unlock")
}
}
}
}
}
private fun showBiometricPrompt(
activity: FragmentActivity,
onUnlocked: () -> Unit
) {
val executor = ContextCompat.getMainExecutor(activity)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onUnlocked()
}
}
val prompt = BiometricPrompt(activity, executor, callback)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock Gudari Wallet")
.setSubtitle("Authenticate to access your wallet")
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_STRONG
or BiometricManager.Authenticators.DEVICE_CREDENTIAL
)
.build()
prompt.authenticate(promptInfo)
}
@@ -0,0 +1,82 @@
package com.bitcointxoko.gudariwallet.ui.onboarding
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Fingerprint
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun BiometricLockPage(
appLockEnabled: Boolean,
onAppLockChanged: (Boolean) -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Filled.Fingerprint,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(24.dp))
Text(
text = "App Lock",
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(12.dp))
Text(
text = "Require biometric authentication (fingerprint, face, or PIN) every time you open Gudari Wallet.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(32.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Lock app on open",
style = MaterialTheme.typography.bodyLarge
)
Text(
text = if (appLockEnabled) "Enabled (recommended)" else "Disabled",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = appLockEnabled,
onCheckedChange = onAppLockChanged
)
}
}
}
}
@@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.AppLockStore
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
import com.bitcointxoko.gudariwallet.security.SecretStore import com.bitcointxoko.gudariwallet.security.SecretStore
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -25,13 +26,15 @@ private const val PAGE_INVOICE = 2
private const val PAGE_ADMIN = 3 private const val PAGE_ADMIN = 3
private const val PAGE_NOTIFICATIONS = 4 private const val PAGE_NOTIFICATIONS = 4
private const val PAGE_BATTERY = 5 private const val PAGE_BATTERY = 5
private const val PAGE_SYNC = 6 private const val PAGE_BIOMETRIC = 6
private const val PAGE_COUNT = 7 private const val PAGE_SYNC = 7
private const val PAGE_COUNT = 8
@Composable @Composable
fun OnboardingScreen( fun OnboardingScreen(
secretStore : SecretStore, secretStore : SecretStore,
syncStore : HistoricalSyncStore, syncStore : HistoricalSyncStore,
appLockStore : AppLockStore,
onRequestNotificationPermission: () -> Unit, onRequestNotificationPermission: () -> Unit,
onComplete : () -> Unit onComplete : () -> Unit
) { ) {
@@ -44,7 +47,7 @@ fun OnboardingScreen(
var adminKey by remember { mutableStateOf("") } var adminKey by remember { mutableStateOf("") }
var adminVisible by remember { mutableStateOf(false) } var adminVisible by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
// var credentialsSaved by remember { mutableStateOf(false) } var appLockEnabled by remember { mutableStateOf(true) }
// ── Sync state ──────────────────────────────────────────────────────────── // ── Sync state ────────────────────────────────────────────────────────────
val vm: OnboardingViewModel = viewModel( val vm: OnboardingViewModel = viewModel(
@@ -119,6 +122,10 @@ fun OnboardingScreen(
onRequestNotificationPermission = onRequestNotificationPermission onRequestNotificationPermission = onRequestNotificationPermission
) )
PAGE_BATTERY -> BatteryPage(strings) PAGE_BATTERY -> BatteryPage(strings)
PAGE_BIOMETRIC -> BiometricLockPage(
appLockEnabled = appLockEnabled,
onAppLockChanged = { appLockEnabled = it }
)
PAGE_SYNC -> SyncHistoryPage( PAGE_SYNC -> SyncHistoryPage(
workInfo = syncWorkInfo, workInfo = syncWorkInfo,
isAlreadyDone = syncAlreadyDone, isAlreadyDone = syncAlreadyDone,
@@ -185,6 +192,9 @@ fun OnboardingScreen(
Timber.d("ONBOARDING saving adminKey (len=${adminKey.length}): $adminKey") Timber.d("ONBOARDING saving adminKey (len=${adminKey.length}): $adminKey")
secretStore.saveAdminKey(adminKey) secretStore.saveAdminKey(adminKey)
} }
PAGE_BIOMETRIC -> {
scope.launch { appLockStore.setAppLockEnabled(appLockEnabled) }
}
else -> Unit else -> Unit
} }
pagerState.animateScrollToPage(pagerState.currentPage + 1) pagerState.animateScrollToPage(pagerState.currentPage + 1)