taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

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:
Mwallet/build.gradle | 1+
Mwallet/src/main/java/net/taler/wallet/MainActivity.kt | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mwallet/src/main/java/net/taler/wallet/MainFragment.kt | 12++++++------
Mwallet/src/main/java/net/taler/wallet/MainViewModel.kt | 77+++++++++++++++--------------------------------------------------------------
Mwallet/src/main/java/net/taler/wallet/settings/SettingsFragment.kt | 38+++++++++++++++++++++++++++++++-------
Mwallet/src/main/java/net/taler/wallet/settings/SettingsManager.kt | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/proto/user_prefs.proto | 2++
Awallet/src/main/res/drawable/ic_shield.xml | 28++++++++++++++++++++++++++++
Mwallet/src/main/res/layout/activity_main.xml | 15+++++++++++++++
Mwallet/src/main/res/values/strings.xml | 8++++++++
Mwallet/src/main/res/xml/settings_main.xml | 7++++++-
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"