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.getValue
|
||||
import cafe.adriel.lyricist.ProvideStrings
|
||||
import com.bitcointxoko.gudariwallet.data.AppLockStore
|
||||
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
|
||||
import com.bitcointxoko.gudariwallet.ui.lock.BiometricLockScreen
|
||||
import com.bitcointxoko.gudariwallet.util.NotificationConstants
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var secretStore: EncryptedSecretStore
|
||||
private lateinit var appLockStore: AppLockStore
|
||||
|
||||
private val isUnlocked = mutableStateOf(false)
|
||||
|
||||
private val vm: WalletViewModel by viewModels {
|
||||
WalletViewModelFactory(application)
|
||||
@@ -84,6 +89,7 @@ class MainActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
secretStore = EncryptedSecretStore(applicationContext)
|
||||
appLockStore = AppLockStore(applicationContext)
|
||||
val syncStore = HistoricalSyncStore(applicationContext)
|
||||
val credentialsState = secretStore.credentials
|
||||
.stateIn(
|
||||
@@ -91,6 +97,12 @@ class MainActivity : AppCompatActivity() {
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
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
|
||||
// NotificationHelper.createChannels(applicationContext)
|
||||
@@ -121,9 +133,25 @@ class MainActivity : AppCompatActivity() {
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
val credentials by credentialsState.collectAsState()
|
||||
val appLockEnabled by appLockEnabledState.collectAsState()
|
||||
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) {
|
||||
WalletNotificationService.start(this@MainActivity)
|
||||
requestNotificationPermissionIfNeeded()
|
||||
@@ -136,18 +164,17 @@ class MainActivity : AppCompatActivity() {
|
||||
pendingPaymentUri = pendingPaymentUri,
|
||||
lyricist = lyricist
|
||||
)
|
||||
} else {
|
||||
OnboardingScreen(
|
||||
secretStore = secretStore,
|
||||
syncStore = syncStore,
|
||||
onRequestNotificationPermission = { requestNotificationPermissionIfNeeded() },
|
||||
onComplete = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
isUnlocked.value = false
|
||||
}
|
||||
|
||||
// ── 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.viewmodel.compose.viewModel
|
||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||
import com.bitcointxoko.gudariwallet.data.AppLockStore
|
||||
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
|
||||
import com.bitcointxoko.gudariwallet.security.SecretStore
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -25,13 +26,15 @@ private const val PAGE_INVOICE = 2
|
||||
private const val PAGE_ADMIN = 3
|
||||
private const val PAGE_NOTIFICATIONS = 4
|
||||
private const val PAGE_BATTERY = 5
|
||||
private const val PAGE_SYNC = 6
|
||||
private const val PAGE_COUNT = 7
|
||||
private const val PAGE_BIOMETRIC = 6
|
||||
private const val PAGE_SYNC = 7
|
||||
private const val PAGE_COUNT = 8
|
||||
|
||||
@Composable
|
||||
fun OnboardingScreen(
|
||||
secretStore : SecretStore,
|
||||
syncStore : HistoricalSyncStore,
|
||||
appLockStore : AppLockStore,
|
||||
onRequestNotificationPermission: () -> Unit,
|
||||
onComplete : () -> Unit
|
||||
) {
|
||||
@@ -44,7 +47,7 @@ fun OnboardingScreen(
|
||||
var adminKey by remember { mutableStateOf("") }
|
||||
var adminVisible by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
// var credentialsSaved by remember { mutableStateOf(false) }
|
||||
var appLockEnabled by remember { mutableStateOf(true) }
|
||||
|
||||
// ── Sync state ────────────────────────────────────────────────────────────
|
||||
val vm: OnboardingViewModel = viewModel(
|
||||
@@ -119,6 +122,10 @@ fun OnboardingScreen(
|
||||
onRequestNotificationPermission = onRequestNotificationPermission
|
||||
)
|
||||
PAGE_BATTERY -> BatteryPage(strings)
|
||||
PAGE_BIOMETRIC -> BiometricLockPage(
|
||||
appLockEnabled = appLockEnabled,
|
||||
onAppLockChanged = { appLockEnabled = it }
|
||||
)
|
||||
PAGE_SYNC -> SyncHistoryPage(
|
||||
workInfo = syncWorkInfo,
|
||||
isAlreadyDone = syncAlreadyDone,
|
||||
@@ -185,6 +192,9 @@ fun OnboardingScreen(
|
||||
Timber.d("ONBOARDING saving adminKey (len=${adminKey.length}): $adminKey")
|
||||
secretStore.saveAdminKey(adminKey)
|
||||
}
|
||||
PAGE_BIOMETRIC -> {
|
||||
scope.launch { appLockStore.setAppLockEnabled(appLockEnabled) }
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
||||
|
||||
Reference in New Issue
Block a user