feat: app biometric lock
This commit is contained in:
@@ -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,9 +133,25 @@ 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 {
|
||||||
|
!isOnboarded -> {
|
||||||
|
OnboardingScreen(
|
||||||
|
secretStore = secretStore,
|
||||||
|
syncStore = syncStore,
|
||||||
|
appLockStore = appLockStore,
|
||||||
|
onRequestNotificationPermission = { requestNotificationPermissionIfNeeded() },
|
||||||
|
onComplete = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
appLockEnabled && !isUnlocked.value -> {
|
||||||
|
BiometricLockScreen(
|
||||||
|
onUnlocked = { isUnlocked.value = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
WalletNotificationService.start(this@MainActivity)
|
WalletNotificationService.start(this@MainActivity)
|
||||||
requestNotificationPermissionIfNeeded()
|
requestNotificationPermissionIfNeeded()
|
||||||
@@ -136,18 +164,17 @@ class MainActivity : AppCompatActivity() {
|
|||||||
pendingPaymentUri = pendingPaymentUri,
|
pendingPaymentUri = pendingPaymentUri,
|
||||||
lyricist = lyricist
|
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 ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user