diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt index b2c673d..9f3a542 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt @@ -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,34 +133,49 @@ class MainActivity : AppCompatActivity() { // ───────────────────────────────────────────────────────────── val credentials by credentialsState.collectAsState() + val appLockEnabled by appLockEnabledState.collectAsState() val isOnboarded = credentials?.isOnboarded == true - if (isOnboarded) { - LaunchedEffect(Unit) { - WalletNotificationService.start(this@MainActivity) - requestNotificationPermissionIfNeeded() + 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() + } + 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 ─────────────────────────────────────────────── override fun onResume() { diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/AppLockStore.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/AppLockStore.kt new file mode 100644 index 0000000..17645c1 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/AppLockStore.kt @@ -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 = 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 + } + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/lock/BiometricLockScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/lock/BiometricLockScreen.kt new file mode 100644 index 0000000..6cf726a --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/lock/BiometricLockScreen.kt @@ -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) +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/BiometricLockPage.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/BiometricLockPage.kt new file mode 100644 index 0000000..2255019 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/BiometricLockPage.kt @@ -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 + ) + } + } + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingScreen.kt index 399f2c9..22d758f 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingScreen.kt @@ -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(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)