commit b71c328c46b883904d511d1e7c314e0ee4ef9fed
parent 1a2d00940e8523f457ae2842a142017dd4940d0e
Author: Iván Ávalos <avalos@disroot.org>
Date: Sun, 6 Jul 2025 22:04:51 +0200
[wallet] implement fingerprint lock
bug 0009931
Diffstat:
11 files changed, 263 insertions(+), 77 deletions(-)
diff --git a/wallet/build.gradle b/wallet/build.gradle
@@ -130,6 +130,7 @@ dependencies {
implementation "com.google.android.material:material:$material_version"
implementation "androidx.constraintlayout:constraintlayout:$constraintlayout_version"
implementation "androidx.browser:browser:1.8.0"
+ implementation "androidx.biometric:biometric-ktx:1.4.0-alpha02"
// Compose
implementation platform('androidx.compose:compose-bom:2025.05.00')
diff --git a/wallet/src/main/java/net/taler/wallet/MainActivity.kt b/wallet/src/main/java/net/taler/wallet/MainActivity.kt
@@ -28,9 +28,14 @@ import android.view.MenuItem
import android.view.View.GONE
import android.view.View.VISIBLE
import android.view.ViewGroup.MarginLayoutParams
+import android.widget.Toast
+import android.widget.Toast.LENGTH_SHORT
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
+import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
+import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
+import androidx.biometric.BiometricPrompt
import androidx.core.os.bundleOf
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
@@ -50,6 +55,7 @@ import com.google.zxing.client.android.Intents.Scan.SCAN_TYPE
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import com.journeyapps.barcodescanner.ScanOptions.QR_CODE
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import net.taler.common.EventObserver
import net.taler.lib.android.TalerNfcService
@@ -63,8 +69,11 @@ class MainActivity : AppCompatActivity(), OnPreferenceStartFragmentCallback {
private lateinit var ui: ActivityMainBinding
private lateinit var nav: NavController
+ private lateinit var biometricPrompt: BiometricPrompt
+ private lateinit var promptInfo: BiometricPrompt.PromptInfo
private val barcodeLauncher = registerForActivityResult(ScanContract()) { result ->
+ model.unlockWallet() // hack to prevent from locking after scanning QR
if (result == null || result.contents == null) return@registerForActivityResult
if (model.checkScanQrContext(result.contents)) {
handleTalerUri(result.contents, "QR code")
@@ -79,6 +88,7 @@ class MainActivity : AppCompatActivity(), OnPreferenceStartFragmentCallback {
ui = ActivityMainBinding.inflate(layoutInflater)
setContentView(ui.root)
setupInsets()
+ setupBiometrics()
TalerNfcService.startService(this)
@@ -105,6 +115,17 @@ class MainActivity : AppCompatActivity(), OnPreferenceStartFragmentCallback {
handleIntents(intent)
+ // Update devMode in model from Datastore API
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ model.settingsManager.getDevModeEnabled(this@MainActivity).collect { enabled ->
+ model.setDevMode(enabled) { error ->
+ showError(error)
+ }
+ }
+ }
+ }
+
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
model.transactionManager.selectedTransaction.collect { tx ->
@@ -125,7 +146,7 @@ class MainActivity : AppCompatActivity(), OnPreferenceStartFragmentCallback {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
model.transactionManager.selectedScope.collect { tx ->
- model.saveSelectedScope(this@MainActivity, tx)
+ model.settingsManager.saveSelectedScope(this@MainActivity, tx)
}
}
}
@@ -172,6 +193,58 @@ class MainActivity : AppCompatActivity(), OnPreferenceStartFragmentCallback {
}
}
+ private fun setupBiometrics() {
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ combine(
+ model.authenticated,
+ model.settingsManager.getBiometricLockEnabled(this@MainActivity)
+ ) { a, b -> a to b }.collect { c ->
+ val authenticated = c.first
+ val biometricEnabled = c.second
+ if (!authenticated && biometricEnabled) {
+ ui.biometricOverlay.visibility = VISIBLE
+ biometricPrompt.authenticate(promptInfo)
+ } else {
+ ui.biometricOverlay.visibility = GONE
+ }
+ }
+ }
+ }
+
+ ui.unlockButton.setOnClickListener {
+ biometricPrompt.authenticate(promptInfo)
+ }
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return
+ biometricPrompt = BiometricPrompt(
+ this,
+ mainExecutor,
+ object : BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
+ super.onAuthenticationError(errorCode, errString)
+ Toast.makeText(this@MainActivity, getString(R.string.biometric_auth_error, errString), LENGTH_SHORT).show()
+ }
+
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ super.onAuthenticationSucceeded(result)
+ model.unlockWallet()
+ }
+
+ override fun onAuthenticationFailed() {
+ super.onAuthenticationFailed()
+ Toast.makeText(this@MainActivity, getString(R.string.biometric_auth_failed), LENGTH_SHORT).show()
+ }
+ },
+ )
+
+ promptInfo = BiometricPrompt.PromptInfo.Builder()
+ .setTitle(getString(R.string.biometric_prompt_title))
+ .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
+ .setConfirmationRequired(true)
+ .build()
+ }
+
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntents(intent)
@@ -265,6 +338,11 @@ class MainActivity : AppCompatActivity(), OnPreferenceStartFragmentCallback {
TalerNfcService.unsetDefaultHandler(this)
}
+ override fun onStop() {
+ super.onStop()
+ model.lockWallet()
+ }
+
override fun onDestroy() {
super.onDestroy()
TalerNfcService.stopService(this)
diff --git a/wallet/src/main/java/net/taler/wallet/MainFragment.kt b/wallet/src/main/java/net/taler/wallet/MainFragment.kt
@@ -123,7 +123,7 @@ class MainFragment: Fragment() {
val txStateFilter by model.transactionManager.stateFilter.collectAsStateLifecycleAware()
val txResult by remember(selectedScope, txStateFilter) { model.transactionManager.transactionsFlow(selectedScope, stateFilter = txStateFilter) }.collectAsStateLifecycleAware()
val selectedSpec = remember(selectedScope) { selectedScope?.let { model.balanceManager.getSpecForScopeInfo(it) } }
- val actionButtonUsed by remember { model.getActionButtonUsed(context) }.collectAsStateLifecycleAware(true)
+ val actionButtonUsed by remember { model.settingsManager.getActionButtonUsed(context) }.collectAsStateLifecycleAware(true)
Scaffold(
bottomBar = {
@@ -144,11 +144,11 @@ class MainFragment: Fragment() {
demandAttention = !actionButtonUsed,
onShowSheet = {
showSheet = true
- model.saveActionButtonUsed(context)
+ model.settingsManager.saveActionButtonUsed(context)
},
onScanQr = {
onScanQr()
- model.saveActionButtonUsed(context)
+ model.settingsManager.saveActionButtonUsed(context)
},
)
@@ -167,7 +167,7 @@ class MainFragment: Fragment() {
LaunchedEffect(Unit) {
if (selectedScope == null) {
model.transactionManager.selectScope(
- model.getSelectedScope(context).first()
+ model.settingsManager.getSelectedScope(context).first()
)
}
}
@@ -463,5 +463,4 @@ fun TalerActionsModal(
}
}
}
-}
-
+}
+\ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/MainViewModel.kt
@@ -17,8 +17,6 @@
package net.taler.wallet
import android.app.Application
-import android.content.Context
-import android.net.Uri
import android.util.Log
import androidx.annotation.UiThread
import androidx.lifecycle.AndroidViewModel
@@ -29,7 +27,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.getAndUpdate
-import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import net.taler.common.Amount
@@ -55,11 +52,10 @@ import net.taler.wallet.payment.PaymentManager
import net.taler.wallet.peer.PeerManager
import net.taler.wallet.refund.RefundManager
import net.taler.wallet.settings.SettingsManager
-import net.taler.wallet.settings.userPreferencesDataStore
import net.taler.wallet.transactions.TransactionManager
import net.taler.wallet.transactions.TransactionStateFilter
import net.taler.wallet.withdraw.WithdrawManager
-import org.json.JSONObject
+import androidx.core.net.toUri
const val TAG = "taler-wallet"
const val OBSERVABILITY_LIMIT = 100
@@ -126,6 +122,9 @@ class MainViewModel(
val accountManager: AccountManager = AccountManager(api, viewModelScope)
val depositManager: DepositManager = DepositManager(api, viewModelScope, balanceManager)
+ private val mAuthenticated = MutableStateFlow(false)
+ val authenticated: StateFlow<Boolean> = mAuthenticated
+
private val mTransactionsEvent = MutableLiveData<Event<ScopeInfo>>()
val transactionsEvent: LiveData<Event<ScopeInfo>> = mTransactionsEvent
@@ -189,6 +188,16 @@ class MainViewModel(
}
}
+ @UiThread
+ fun lockWallet() {
+ mAuthenticated.value = false
+ }
+
+ @UiThread
+ fun unlockWallet() {
+ mAuthenticated.value = true
+ }
+
/**
* Navigates to the given scope info's transaction list, when [MainFragment] is shown.
*/
@@ -215,24 +224,6 @@ class MainViewModel(
balanceManager.resetBalances()
}
- fun startTunnel() {
- viewModelScope.launch {
- api.sendRequest("startTunnel")
- }
- }
-
- fun stopTunnel() {
- viewModelScope.launch {
- api.sendRequest("stopTunnel")
- }
- }
-
- fun tunnelResponse(resp: String) {
- viewModelScope.launch {
- api.sendRequest("tunnelResponse", JSONObject(resp))
- }
- }
-
@UiThread
fun scanCode(context: ScanQrContext = ScanQrContext.Unknown) {
scanQrContext = context
@@ -242,7 +233,7 @@ class MainViewModel(
fun getScanQrContext() = scanQrContext
fun checkScanQrContext(uri: String): Boolean {
- val parsed = Uri.parse(uri)
+ val parsed = uri.toUri()
val action = parsed.host
return when (scanQrContext) {
ScanQrContext.Send -> action in sendUriActions
@@ -298,44 +289,6 @@ class MainViewModel(
}.onError(onError)
}
}
-
- fun getSelectedScope(c: Context) = c.userPreferencesDataStore.data.map { prefs ->
- if (prefs.hasSelectedScope()) {
- ScopeInfo.fromPrefs(prefs.selectedScope)
- } else {
- null
- }
- }
-
- fun saveSelectedScope(c: Context, scopeInfo: ScopeInfo?) = viewModelScope.launch {
- c.userPreferencesDataStore.updateData { current ->
- if (scopeInfo != null) {
- current.toBuilder()
- .setSelectedScope(scopeInfo.toPrefs())
- .build()
- } else {
- current.toBuilder()
- .clearSelectedScope()
- .build()
- }
- }
- }
-
- fun getActionButtonUsed(c: Context) = c.userPreferencesDataStore.data.map { prefs ->
- if (prefs.hasActionButtonUsed()) {
- prefs.actionButtonUsed
- } else {
- false
- }
- }
-
- fun saveActionButtonUsed(c: Context) = viewModelScope.launch {
- c.userPreferencesDataStore.updateData { current ->
- current.toBuilder()
- .setActionButtonUsed(true)
- .build()
- }
- }
}
enum class ScanQrContext {
diff --git a/wallet/src/main/java/net/taler/wallet/settings/SettingsFragment.kt b/wallet/src/main/java/net/taler/wallet/settings/SettingsFragment.kt
@@ -16,6 +16,7 @@
package net.taler.wallet.settings
+import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
@@ -27,6 +28,7 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
+import androidx.preference.SwitchPreference
import androidx.preference.SwitchPreferenceCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG
@@ -50,7 +52,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
private val settingsManager get() = model.settingsManager
private val withdrawManager by lazy { model.withdrawManager }
- private lateinit var prefDevMode: SwitchPreferenceCompat
+ private lateinit var prefDevMode: SwitchPreference
+ private lateinit var prefBiometricLock: SwitchPreference
private lateinit var prefWithdrawTest: Preference
private lateinit var prefLogcat: Preference
private lateinit var prefExportDb: Preference
@@ -93,6 +96,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings_main, rootKey)
prefDevMode = findPreference("pref_dev_mode")!!
+ prefBiometricLock = findPreference("pref_biometric_lock")!!
prefWithdrawTest = findPreference("pref_testkudos")!!
prefLogcat = findPreference("pref_logcat")!!
prefExportDb = findPreference("pref_export_db")!!
@@ -113,14 +117,34 @@ class SettingsFragment : PreferenceFragmentCompat() {
model.exchangeVersion?.let { prefVersionExchange.summary = it }
model.merchantVersion?.let { prefVersionMerchant.summary = it }
- model.devMode.observe(viewLifecycleOwner) { enabled ->
- prefDevMode.isChecked = enabled
- devPrefs.forEach { it.isVisible = enabled }
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
+ prefBiometricLock.isVisible = false
}
- prefDevMode.setOnPreferenceChangeListener { _, newValue ->
- model.setDevMode(newValue as Boolean) { error ->
- showError(error)
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ settingsManager.getBiometricLockEnabled(requireContext()).collect { enabled ->
+ prefBiometricLock.isChecked = enabled
+ }
}
+ }
+
+ prefBiometricLock.setOnPreferenceChangeListener { _, newValue ->
+ settingsManager.setBiometricLockEnabled(requireContext(), newValue as Boolean)
+ true
+ }
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ settingsManager.getDevModeEnabled(requireContext()).collect { enabled ->
+ prefDevMode.isChecked = enabled
+ devPrefs.forEach { it.isVisible = enabled }
+ }
+ }
+ }
+
+ prefDevMode.setOnPreferenceChangeListener { _, newValue ->
+ settingsManager.setDevModeEnabled(requireContext(), newValue as Boolean)
true
}
diff --git a/wallet/src/main/java/net/taler/wallet/settings/SettingsManager.kt b/wallet/src/main/java/net/taler/wallet/settings/SettingsManager.kt
@@ -23,6 +23,7 @@ import android.widget.Toast
import android.widget.Toast.LENGTH_LONG
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
@@ -32,6 +33,7 @@ import net.taler.wallet.backend.WalletBackendApi
import net.taler.wallet.backend.WalletResponse.Error
import net.taler.wallet.backend.WalletResponse.Success
import net.taler.wallet.balances.BalanceManager
+import net.taler.wallet.balances.ScopeInfo
import org.json.JSONObject
class SettingsManager(
@@ -40,6 +42,76 @@ class SettingsManager(
private val scope: CoroutineScope,
private val balanceManager: BalanceManager,
) {
+ fun getSelectedScope(c: Context) = c.userPreferencesDataStore.data.map { prefs ->
+ if (prefs.hasSelectedScope()) {
+ ScopeInfo.fromPrefs(prefs.selectedScope)
+ } else {
+ null
+ }
+ }
+
+ fun saveSelectedScope(c: Context, scopeInfo: ScopeInfo?) = scope.launch {
+ c.userPreferencesDataStore.updateData { current ->
+ if (scopeInfo != null) {
+ current.toBuilder()
+ .setSelectedScope(scopeInfo.toPrefs())
+ .build()
+ } else {
+ current.toBuilder()
+ .clearSelectedScope()
+ .build()
+ }
+ }
+ }
+
+ fun getActionButtonUsed(c: Context) = c.userPreferencesDataStore.data.map { prefs ->
+ if (prefs.hasActionButtonUsed()) {
+ prefs.actionButtonUsed
+ } else {
+ false
+ }
+ }
+
+ fun saveActionButtonUsed(c: Context) = scope.launch {
+ c.userPreferencesDataStore.updateData { current ->
+ current.toBuilder()
+ .setActionButtonUsed(true)
+ .build()
+ }
+ }
+
+ fun getDevModeEnabled(c: Context) = c.userPreferencesDataStore.data.map { prefs ->
+ if (prefs.hasDevModeEnabled()) {
+ prefs.devModeEnabled
+ } else {
+ false
+ }
+ }
+
+ fun setDevModeEnabled(c: Context, enabled: Boolean) = scope.launch {
+ c.userPreferencesDataStore.updateData { current ->
+ current.toBuilder()
+ .setDevModeEnabled(enabled)
+ .build()
+ }
+ }
+
+ fun getBiometricLockEnabled(c: Context) = c.userPreferencesDataStore.data.map { prefs ->
+ if (prefs.hasBiometricLockEnabled()) {
+ prefs.biometricLockEnabled
+ } else {
+ false
+ }
+ }
+
+ fun setBiometricLockEnabled(c: Context, enabled: Boolean) = scope.launch {
+ c.userPreferencesDataStore.updateData { current ->
+ current.toBuilder()
+ .setBiometricLockEnabled(enabled)
+ .build()
+ }
+ }
+
fun exportLogcat(uri: Uri?) {
if (uri == null) {
onLogExportError()
diff --git a/wallet/src/main/proto/user_prefs.proto b/wallet/src/main/proto/user_prefs.proto
@@ -18,4 +18,6 @@ message PrefsScopeInfo {
message UserPreferences {
optional PrefsScopeInfo selectedScope = 1;
optional bool actionButtonUsed = 2;
+ optional bool devModeEnabled = 3;
+ optional bool biometricLockEnabled = 4;
}
\ No newline at end of file
diff --git a/wallet/src/main/res/drawable/ic_shield.xml b/wallet/src/main/res/drawable/ic_shield.xml
@@ -0,0 +1,28 @@
+<!--
+ ~ This file is part of GNU Taler
+ ~ (C) 2025 Taler Systems S.A.
+ ~
+ ~ GNU Taler is free software; you can redistribute it and/or modify it under the
+ ~ terms of the GNU General Public License as published by the Free Software
+ ~ Foundation; either version 3, or (at your option) any later version.
+ ~
+ ~ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ ~ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ ~ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ ~
+ ~ You should have received a copy of the GNU General Public License along with
+ ~ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ -->
+
+<vector
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:height="24dp"
+ android:tint="?attr/colorControlNormal"
+ android:viewportHeight="960"
+ android:viewportWidth="960"
+ android:width="24dp">
+
+ <path android:fillColor="#FF000000"
+ android:pathData="M480,880Q341,845 250.5,720.5Q160,596 160,444L160,200L480,80L800,200L800,444Q800,596 709.5,720.5Q619,845 480,880ZM480,796Q584,763 652,664Q720,565 720,444L720,255L480,165L240,255L240,444Q240,565 308,664Q376,763 480,796ZM480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480ZM400,640L560,640Q577,640 588.5,628.5Q600,617 600,600L600,480Q600,463 588.5,451.5Q577,440 560,440L560,440L560,400Q560,367 536.5,343.5Q513,320 480,320Q447,320 423.5,343.5Q400,367 400,400L400,440L400,440Q383,440 371.5,451.5Q360,463 360,480L360,600Q360,617 371.5,628.5Q383,640 400,640ZM440,440L440,400Q440,383 451.5,371.5Q463,360 480,360Q497,360 508.5,371.5Q520,383 520,400L520,440L440,440Z"/>
+
+</vector>
diff --git a/wallet/src/main/res/layout/activity_main.xml b/wallet/src/main/res/layout/activity_main.xml
@@ -78,4 +78,19 @@
</LinearLayout>
+ <LinearLayout
+ android:id="@+id/biometricOverlay"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:gravity="center"
+ android:background="?colorSurface"
+ tools:visibility="gone">
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/unlockButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/biometric_unlock_label"
+ app:icon="@drawable/ic_shield"/>
+ </LinearLayout>
+
</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml
@@ -75,6 +75,12 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card
<string name="share_payment">Share payment link</string>
<string name="uri_invalid">Not a valid Taler URI</string>
+ <!-- Biometric lock -->
+ <string name="biometric_auth_error">Authentication error: %1$s</string>
+ <string name="biometric_auth_failed">Authentication failed</string>
+ <string name="biometric_prompt_title">Unlock to use wallet</string>
+ <string name="biometric_unlock_label">Tap to unlock</string>
+
<!-- QR code handling -->
<string name="qr_scan_context_title">Possibly unintended action</string>
@@ -398,6 +404,8 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card
<string name="settings_dev_mode_summary">Shows more information intended for debugging</string>
<string name="settings_dialog_import_message">This operation will overwrite your existing database. Do you want to continue?</string>
<string name="settings_dialog_reset_message">Do you really want to reset the wallet and lose all coins and purchases?</string>
+ <string name="settings_lock_auth">Lock with fingerprint</string>
+ <string name="settings_lock_auth_summary">Require fingerprint or password to access the wallet</string>
<string name="settings_logcat">Debug log</string>
<string name="settings_logcat_error">Error exporting log</string>
<string name="settings_logcat_success">Log exported to file</string>
diff --git a/wallet/src/main/res/xml/settings_main.xml b/wallet/src/main/res/xml/settings_main.xml
@@ -24,8 +24,13 @@
app:summary="@string/exchange_settings_summary"
app:title="@string/exchange_settings_title" />
+ <SwitchPreference
+ app:icon="@drawable/ic_shield"
+ app:key="pref_biometric_lock"
+ app:title="@string/settings_lock_auth"
+ app:summary="@string/settings_lock_auth_summary"/>
- <SwitchPreferenceCompat
+ <SwitchPreference
app:icon="@drawable/ic_developer_mode"
app:key="pref_dev_mode"
app:summary="@string/settings_dev_mode_summary"