commit 699466d892a2072b9d20c148ad4145fe39497cd6
parent d16a3850b2c2e560090b1a52c83b7c89919ae525
Author: Iván Ávalos <avalos@disroot.org>
Date: Fri, 15 May 2026 19:08:30 +0200
[cashier] 2FA support, target v10+
Diffstat:
7 files changed, 541 insertions(+), 40 deletions(-)
diff --git a/cashier/build.gradle b/cashier/build.gradle
@@ -33,7 +33,7 @@ android {
versionName "1.1.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
- buildConfigField("String", "BACKEND_API_VERSION", "\"9:0:4\"")
+ buildConfigField("String", "BACKEND_API_VERSION", "\"12:0:4\"")
}
buildTypes {
diff --git a/cashier/src/main/java/net/taler/cashier/HttpHelper.kt b/cashier/src/main/java/net/taler/cashier/HttpHelper.kt
@@ -20,7 +20,6 @@ import android.util.Log
import androidx.annotation.WorkerThread
import net.taler.cashier.config.Config
import okhttp3.Authenticator
-import okhttp3.Credentials
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -40,10 +39,13 @@ object HttpHelper {
val request = Request.Builder()
.addHeader("Accept", MIME_TYPE_JSON)
.url(url)
+ .apply {
+ config.bearerAuth?.let { header("Authorization", it) }
+ }
.get()
.build()
val response = try {
- getHttpClient(config.username, config.password)
+ getHttpClient(config)
.newCall(request)
.execute()
} catch (e: Exception) {
@@ -68,10 +70,13 @@ object HttpHelper {
val request = Request.Builder()
.addHeader("Accept", MIME_TYPE_JSON)
.url(url)
+ .apply {
+ config.bearerAuth?.let { header("Authorization", it) }
+ }
.post(body.toString().toRequestBody(MEDIA_TYPE_JSON))
.build()
val response = try {
- getHttpClient(config.username, config.password)
+ getHttpClient(config)
.newCall(request)
.execute()
} catch (e: Exception) {
@@ -89,10 +94,11 @@ object HttpHelper {
}
}
- private fun getHttpClient(username: String, password: String) =
+ private fun getHttpClient(config: Config) =
OkHttpClient.Builder().authenticator(object : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
- val credential = Credentials.basic(username, password)
+ if (config.password.isEmpty()) return null
+ val credential = config.basicAuth
if (credential == response.request.header("Authorization")) {
// If we already failed with these credentials, don't retry
return null
diff --git a/cashier/src/main/java/net/taler/cashier/config/Config.kt b/cashier/src/main/java/net/taler/cashier/config/Config.kt
@@ -16,25 +16,171 @@
package net.taler.cashier.config
+import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.descriptors.buildClassSerialDescriptor
+import kotlinx.serialization.descriptors.element
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.json.JsonDecoder
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonEncoder
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.long
+import kotlinx.serialization.json.longOrNull
+import net.taler.common.Timestamp
import okhttp3.Credentials
+@Serializable
+data class TokenSuccessResponse(
+ val expiration: Timestamp,
+
+ @SerialName("access_token")
+ val accessToken: String,
+)
+
+sealed class TokenResult {
+ data class Success(
+ val expiration: Timestamp,
+ val accessToken: String,
+ ): TokenResult()
+
+ data class TanRequired(
+ val challenges: List<Challenge>,
+ val combiAnd: Boolean,
+ ): TokenResult()
+
+ data class Error(
+ val authError: Boolean,
+ val msg: String,
+ ): TokenResult()
+}
+
data class Config(
val bankUrl: String,
val username: String,
- val password: String
+ val password: String = "",
+ val accessToken: String? = null,
+ val expiration: Timestamp? = null,
) {
val basicAuth: String get() = Credentials.basic(username, password)
+ val bearerAuth: String? get() = accessToken?.let { "Bearer $it" }
+
+ fun isTokenValid(): Boolean {
+ if (accessToken == null || expiration == null) return false
+ return expiration > Timestamp.now()
+ }
}
@Serializable
data class ConfigResponse(
val version: String,
- val currency: String
+ val currency: String,
)
+@Serializable
+enum class TanChannel {
+ @SerialName("sms")
+ SMS,
+
+ @SerialName("email")
+ EMAIL,
+}
+
+@Serializable
+data class Challenge(
+ @SerialName("challenge_id")
+ val challengeId: String,
+
+ @SerialName("tan_channel")
+ val tanChannel: TanChannel,
+
+ @SerialName("tan_info")
+ val tanInfo: String,
+)
+
+@Serializable
+data class ChallengesResponse(
+ val challenges: List<Challenge>,
+
+ // True if **all** challenges must be solved (AND), false if
+ // it is sufficient to solve one of them (OR).
+ @SerialName("combi_and")
+ val combiAnd: Boolean,
+)
+
+@Serializable
+data class ChallengeConfirmRequest(
+ val tan: String,
+)
+
+@Serializable
+data class TokenRequest(
+ val scope: String,
+ val duration: TokenDuration
+)
+
+@Serializable(with = TokenDuration.Serializer::class)
+sealed class TokenDuration {
+ data object Forever : TokenDuration()
+ data class Micros(val us: Long) : TokenDuration()
+
+ object Serializer : KSerializer<TokenDuration> {
+ // describe an object with a single property "d_us"
+ override val descriptor: SerialDescriptor = buildClassSerialDescriptor("TokenDuration") {
+ element<JsonElement>("d_us")
+ }
+
+ override fun serialize(encoder: Encoder, value: TokenDuration) {
+ // we need a Json-specific encoder
+ val jsonEncoder = encoder as? JsonEncoder
+ ?: throw SerializationException("Can be serialized only by JSON")
+ // build the JSON object
+ val obj = when (value) {
+ is Forever -> buildJsonObject {
+ // for "forever", we still emit an object,
+ // here storing the literal string under "d_us"
+ put("d_us", JsonPrimitive("forever"))
+ }
+ is Micros -> buildJsonObject {
+ put("d_us", JsonPrimitive(value.us))
+ }
+ }
+ jsonEncoder.encodeJsonElement(obj)
+ }
+
+ override fun deserialize(decoder: Decoder): TokenDuration {
+ // we need a Json-specific decoder
+ val jsonDecoder = decoder as? JsonDecoder
+ ?: throw SerializationException("Can be deserialized only by JSON")
+ val element = jsonDecoder.decodeJsonElement()
+ if (element !is JsonObject) {
+ throw SerializationException("Expected JSON object for TokenDuration, got: $element")
+ }
+ // look up our single field
+ val field = element["d_us"]
+ ?: throw SerializationException("Missing 'd_us' field in $element")
+ return when {
+ field is JsonPrimitive && field.longOrNull != null ->
+ Micros(field.long)
+ field is JsonPrimitive && field.isString && field.content == "forever" ->
+ Forever
+ else ->
+ throw SerializationException("Invalid 'd_us' value: $field")
+ }
+ }
+ }
+}
+
sealed class ConfigResult {
- class Error(val authError: Boolean, val msg: String) : ConfigResult()
- object Offline : ConfigResult()
- object Success : ConfigResult()
+ data class Error(val authError: Boolean, val msg: String) : ConfigResult()
+ data object Offline : ConfigResult()
+ data object Success : ConfigResult()
+ data object Unknown : ConfigResult()
+ data class TanRequired(val challenges: List<Challenge>, val combiAnd: Boolean): ConfigResult()
}
diff --git a/cashier/src/main/java/net/taler/cashier/config/ConfigFragment.kt b/cashier/src/main/java/net/taler/cashier/config/ConfigFragment.kt
@@ -24,20 +24,31 @@ import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
+import android.widget.TextView
+import android.widget.Toast
import androidx.core.content.ContextCompat.getSystemService
import androidx.core.text.HtmlCompat
import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Observer
+import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG
import com.google.android.material.snackbar.Snackbar
+import com.google.android.material.textfield.TextInputEditText
+import com.google.android.material.textfield.TextInputLayout
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
import net.taler.cashier.MainViewModel
import net.taler.cashier.R
import net.taler.cashier.databinding.FragmentConfigBinding
import net.taler.common.exhaustive
import net.taler.common.showError
+import kotlin.coroutines.resume
private const val URL_BANK_TEST = "https://bank.demo.taler.net"
private const val URL_BANK_TEST_REGISTER = "https://bank.demo.taler.net/webui/#/register"
@@ -81,7 +92,7 @@ class ConfigFragment : Fragment() {
if (checkConfig(config)) {
// show progress
ui.saveButton.visibility = INVISIBLE
- // ui.progressBar.visibility = VISIBLE
+ ui.progressBar.visibility = VISIBLE
// kick off check and observe result
configManager.checkAndSaveConfig(config)
configManager.configResult.observe(viewLifecycleOwner, onConfigResult)
@@ -150,10 +161,174 @@ class ConfigFragment : Fragment() {
requireActivity().showError(getString(R.string.config_error), result.msg)
}
}
+ is ConfigResult.TanRequired -> {
+ lifecycleScope.launch {
+ val solvedIds = handleChallengeResponse(
+ ui.urlView.editText!!.text.toString().trim(),
+ ui.usernameView.editText!!.text.toString().trim(),
+ result.challenges,
+ result.combiAnd
+ )
+ if (solvedIds.isNotEmpty()) {
+ val config = Config(
+ bankUrl = ui.urlView.editText!!.text.toString().trim(),
+ username = ui.usernameView.editText!!.text.toString().trim(),
+ password = ui.passwordView.editText!!.text.toString().trim()
+ )
+ configManager.checkAndSaveConfig(config, solvedIds)
+ } else {
+ ui.saveButton.visibility = VISIBLE
+ ui.progressBar.visibility = INVISIBLE
+ configManager.configResult.removeObservers(viewLifecycleOwner)
+ }
+ }
+ return@Observer
+ }
+ ConfigResult.Unknown -> {
+ requireActivity().showError(getString(R.string.config_error), "Unknown error")
+ }
}.exhaustive
ui.saveButton.visibility = VISIBLE
ui.progressBar.visibility = INVISIBLE
configManager.configResult.removeObservers(viewLifecycleOwner)
}
+ private suspend fun handleChallengeResponse(
+ baseUrl: String,
+ username: String,
+ challenges: List<Challenge>,
+ combiAnd: Boolean,
+ ): List<String> {
+ if (challenges.isEmpty()) return emptyList()
+ val challengesToSolve = if (combiAnd) {
+ challenges
+ } else {
+ val selected = selectChallenge(challenges) ?: return emptyList()
+ listOf(selected)
+ }
+
+ val solvedIds = mutableListOf<String>()
+ for (challenge in challengesToSolve) {
+ withContext(Dispatchers.IO) {
+ configManager.requestChallenge(baseUrl, username, challenge.challengeId)
+ }
+
+ while (true) {
+ val tan = promptForTan(challenge) ?: return emptyList()
+ try {
+ withContext(Dispatchers.IO) {
+ configManager.confirmChallenge(
+ baseUrl,
+ username,
+ challenge.challengeId,
+ tan
+ )
+ }
+ solvedIds.add(challenge.challengeId)
+ break
+ } catch (e: Exception) {
+ when (handleChallengeConfirmError(e)) {
+ ChallengeRetryDecision.Retry -> continue
+ ChallengeRetryDecision.Resend -> {
+ withContext(Dispatchers.IO) {
+ configManager.requestChallenge(
+ baseUrl,
+ username,
+ challenge.challengeId
+ )
+ }
+ continue
+ }
+ ChallengeRetryDecision.Abort -> return emptyList()
+ }
+ }
+ }
+ }
+ return solvedIds
+ }
+
+ private suspend fun selectChallenge(challenges: List<Challenge>): Challenge? =
+ withContext(Dispatchers.Main) {
+ suspendCancellableCoroutine { cont ->
+ val labels = challenges.map { c ->
+ "${c.tanChannel}: ${c.tanInfo}"
+ }.toTypedArray()
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.mfa_choose_title)
+ .setItems(labels) { _, which ->
+ cont.resume(challenges[which])
+ }
+ .setOnCancelListener { cont.resume(null) }
+ .show()
+ }
+ }
+
+ private suspend fun promptForTan(challenge: Challenge): String? =
+ withContext(Dispatchers.Main) {
+ suspendCancellableCoroutine { cont ->
+ val message = getString(
+ R.string.mfa_challenge_message,
+ challenge.tanChannel.name,
+ challenge.tanInfo
+ )
+ val dialogView = layoutInflater.inflate(
+ R.layout.dialog_mfa_challenge,
+ null,
+ false
+ )
+ val messageView = dialogView.findViewById<TextView>(R.id.mfaMessageView)
+ val inputLayout = dialogView.findViewById<TextInputLayout>(R.id.mfaCodeInputLayout)
+ val input = dialogView.findViewById<TextInputEditText>(R.id.mfaCodeInput)
+ messageView.text = message
+ inputLayout.isErrorEnabled = false
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.mfa_challenge_title)
+ .setView(dialogView)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ cont.resume(input?.text?.toString()?.trim().orEmpty())
+ }
+ .setNegativeButton(android.R.string.cancel) { _, _ ->
+ cont.resume(null)
+ }
+ .setOnCancelListener { cont.resume(null) }
+ .show()
+ }
+ }
+
+ private suspend fun handleChallengeConfirmError(e: Exception): ChallengeRetryDecision =
+ withContext(Dispatchers.Main) {
+ if (e is io.ktor.client.plugins.ClientRequestException) {
+ when (e.response.status.value) {
+ 409 -> {
+ Toast.makeText(
+ requireContext(),
+ R.string.mfa_challenge_invalid,
+ Toast.LENGTH_LONG
+ ).show()
+ return@withContext ChallengeRetryDecision.Retry
+ }
+ 429 -> {
+ Toast.makeText(
+ requireContext(),
+ R.string.mfa_challenge_retry,
+ Toast.LENGTH_LONG
+ ).show()
+ return@withContext ChallengeRetryDecision.Resend
+ }
+ }
+ }
+ Toast.makeText(
+ requireContext(),
+ R.string.mfa_challenge_failed,
+ Toast.LENGTH_LONG
+ ).show()
+ ChallengeRetryDecision.Abort
+ }
+
+ private enum class ChallengeRetryDecision {
+ Retry,
+ Resend,
+ Abort
+ }
+
}
diff --git a/cashier/src/main/java/net/taler/cashier/config/ConfigManager.kt b/cashier/src/main/java/net/taler/cashier/config/ConfigManager.kt
@@ -21,6 +21,8 @@ import android.app.Application
import android.util.Log
import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
+import androidx.core.content.edit
+import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.security.crypto.EncryptedSharedPreferences
@@ -32,15 +34,20 @@ import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.header
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders.Authorization
import io.ktor.http.HttpStatusCode.Companion.Unauthorized
+import io.ktor.http.contentType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.buildJsonObject
import net.taler.cashier.BuildConfig
-import net.taler.cashier.Response
import net.taler.cashier.Response.Companion.response
+import net.taler.common.Timestamp
import net.taler.common.Version
import net.taler.common.getIncompatibleStringOrNull
@@ -49,6 +56,8 @@ private const val PREF_NAME = "net.taler.cashier.prefs"
private const val PREF_KEY_BANK_URL = "bankUrl"
private const val PREF_KEY_USERNAME = "username"
private const val PREF_KEY_PASSWORD = "password"
+private const val PREF_KEY_ACCESS_TOKEN = "accessToken"
+private const val PREF_KEY_EXPIRATION = "expiration"
private const val PREF_KEY_CURRENCY = "currency"
private val TAG = ConfigManager::class.java.simpleName
@@ -69,7 +78,9 @@ class ConfigManager(
internal var config = Config(
bankUrl = prefs.getString(PREF_KEY_BANK_URL, "")!!,
username = prefs.getString(PREF_KEY_USERNAME, "")!!,
- password = prefs.getString(PREF_KEY_PASSWORD, "")!!
+ password = prefs.getString(PREF_KEY_PASSWORD, "")!!,
+ accessToken = prefs.getString(PREF_KEY_ACCESS_TOKEN, null),
+ expiration = prefs.getLong(PREF_KEY_EXPIRATION, 0).let { if (it == 0L) null else Timestamp.fromMillis(it) }
)
private val mCurrency = MutableLiveData<String>(
@@ -89,8 +100,14 @@ class ConfigManager(
* Warning: Ignore null results that are used to reset old results.
*/
@UiThread
- fun checkAndSaveConfig(config: Config) = scope.launch {
+ fun checkAndSaveConfig(config: Config, challengeIds: List<String> = emptyList()) = scope.launch {
mConfigResult.value = null
+ if (config.isTokenValid()) {
+ this@ConfigManager.config = config
+ mCurrency.postValue(prefs.getString(PREF_KEY_CURRENCY, null))
+ mConfigResult.postValue(ConfigResult.Success)
+ return@launch
+ }
checkConfig(config).onError { failure ->
val result = if (failure.isOffline(app)) {
ConfigResult.Offline
@@ -104,37 +121,123 @@ class ConfigManager(
val result = if (versionIncompatible != null) {
ConfigResult.Error(false, versionIncompatible)
} else {
- mCurrency.postValue(response.currency)
- prefs.edit().putString(PREF_KEY_CURRENCY, response.currency).apply()
- // save config
- saveConfig(config)
- ConfigResult.Success
+ // get access token
+ when (val tokenRes = getToken(config, challengeIds)) {
+ is TokenResult.Success, is TokenResult.TanRequired -> {
+ mCurrency.postValue(response.currency)
+ prefs.edit { putString(PREF_KEY_CURRENCY, response.currency) }
+ // save config
+ if (tokenRes is TokenResult.Success) {
+ saveConfig(config.copy(
+ accessToken = tokenRes.accessToken,
+ expiration = tokenRes.expiration
+ ))
+ } else {
+ saveConfig(config)
+ }
+ when (tokenRes) {
+ is TokenResult.Success -> ConfigResult.Success
+ is TokenResult.TanRequired -> ConfigResult.TanRequired(tokenRes.challenges, tokenRes.combiAnd)
+ }
+ }
+
+ is TokenResult.Error -> {
+ ConfigResult.Error(tokenRes.authError, tokenRes.msg)
+ }
+
+ else -> ConfigResult.Unknown
+ }
}
mConfigResult.postValue(result)
}
}
private suspend fun checkConfig(config: Config) = withContext(Dispatchers.IO) {
- val url = "${config.bankUrl}/config"
+ val url = config.bankUrl.toUri()
+ .buildUpon()
+ .appendPath("config")
+ .build()
+ .toString()
Log.d(TAG, "Checking config: $url")
- val configResponse = response {
+ response {
httpClient.get(url).body<ConfigResponse>()
}
- if (configResponse.isFailure) {
- configResponse
- } else {
- // we need to check an endpoint that requires authentication as well
- // to see if the credentials are valid
- val balanceResponse = response {
- val authUrl = "${config.bankUrl}/accounts/${config.username}"
- Log.d(TAG, "Checking auth: $authUrl")
- httpClient.get(authUrl) {
- header(Authorization, config.basicAuth)
+ }
+
+ private suspend fun getToken(config: Config, challengeIds: List<String> = emptyList()): TokenResult? = withContext(Dispatchers.IO) {
+ // fetch authentication token
+ val tokenUrl = config.bankUrl.toUri()
+ .buildUpon()
+ .appendPath("accounts")
+ .appendPath(config.username)
+ .appendPath("token")
+ .build()
+ .toString()
+ var result: TokenResult? = null
+ response {
+ val res = httpClient.post(tokenUrl) {
+ header(Authorization, config.basicAuth)
+ if (challengeIds.isNotEmpty()) {
+ header("Taler-Challenge-Ids", challengeIds.joinToString(","))
}
+ contentType(ContentType.Application.Json)
+ setBody(TokenRequest(scope = "readwrite", duration = TokenDuration.Forever))
+ }
+
+ return@response when (res.status.value) {
+ 200 -> res.body<TokenSuccessResponse>()
+ 202 -> res.body<ChallengesResponse>()
+ else -> res.body<Unit>()
}
- @Suppress("UNCHECKED_CAST") // The type doesn't matter for failures
- if (balanceResponse.isFailure) balanceResponse as Response<ConfigResponse>
- else configResponse
+ }.onSuccess { res ->
+ if (res is TokenSuccessResponse) {
+ result = TokenResult.Success(res.expiration, res.accessToken)
+ } else if (res is ChallengesResponse) {
+ result = TokenResult.TanRequired(res.challenges, res.combiAnd)
+ }
+ }.onError { err ->
+ result = TokenResult.Error(err.statusCode == Unauthorized, err.msg)
+ }
+ return@withContext result
+ }
+
+ suspend fun requestChallenge(
+ baseUrl: String,
+ username: String,
+ challengeId: String
+ ) = withContext(Dispatchers.IO) {
+ val challengeUrl = baseUrl.toUri()
+ .buildUpon()
+ .appendPath("accounts")
+ .appendPath(username)
+ .appendPath("challenge")
+ .appendPath(challengeId)
+ .build()
+ .toString()
+ httpClient.post(challengeUrl) {
+ contentType(ContentType.Application.Json)
+ setBody(buildJsonObject { })
+ }
+ }
+
+ suspend fun confirmChallenge(
+ baseUrl: String,
+ username: String,
+ challengeId: String,
+ tan: String
+ ) = withContext(Dispatchers.IO) {
+ val confirmUrl = baseUrl.toUri()
+ .buildUpon()
+ .appendPath("accounts")
+ .appendPath(username)
+ .appendPath("challenge")
+ .appendPath(challengeId)
+ .appendPath("confirm")
+ .build()
+ .toString()
+ httpClient.post(confirmUrl) {
+ contentType(ContentType.Application.Json)
+ setBody(ChallengeConfirmRequest(tan = tan))
}
}
@@ -142,11 +245,21 @@ class ConfigManager(
@SuppressLint("ApplySharedPref")
internal fun saveConfig(config: Config) {
this.config = config
- prefs.edit()
- .putString(PREF_KEY_BANK_URL, config.bankUrl)
- .putString(PREF_KEY_USERNAME, config.username)
- .putString(PREF_KEY_PASSWORD, config.password)
- .commit()
+ prefs.edit(commit = true) {
+ putString(PREF_KEY_BANK_URL, config.bankUrl)
+ putString(PREF_KEY_USERNAME, config.username)
+ putString(PREF_KEY_PASSWORD, config.password)
+ if (config.accessToken != null) {
+ putString(PREF_KEY_ACCESS_TOKEN, config.accessToken)
+ } else {
+ remove(PREF_KEY_ACCESS_TOKEN)
+ }
+ if (config.expiration != null) {
+ putLong(PREF_KEY_EXPIRATION, config.expiration.ms)
+ } else {
+ remove(PREF_KEY_EXPIRATION)
+ }
+ }
}
@WorkerThread
diff --git a/cashier/src/main/res/layout/dialog_mfa_challenge.xml b/cashier/src/main/res/layout/dialog_mfa_challenge.xml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<!--
+ ~ This file is part of GNU Taler
+ ~ (C) 2026 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/>
+ -->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:paddingStart="24dp"
+ android:paddingTop="16dp"
+ android:paddingEnd="24dp"
+ android:paddingBottom="8dp">
+
+ <TextView
+ android:id="@+id/mfaMessageView"
+ style="@style/TextAppearance.Material3.BodyMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="16dp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:id="@+id/mfaCodeInputLayout"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:hint="@string/mfa_challenge_code_hint"
+ app:boxBackgroundMode="outline"
+ app:boxBackgroundColor="@android:color/transparent">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/mfaCodeInput"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:imeOptions="actionDone"
+ android:inputType="number" />
+ </com.google.android.material.textfield.TextInputLayout>
+
+</LinearLayout>
diff --git a/cashier/src/main/res/values/strings.xml b/cashier/src/main/res/values/strings.xml
@@ -56,4 +56,12 @@
<string name="about_supported_bank_api">Bank API Version: %s</string>
<string name="host_apdu_service_desc">Taler Cashier NFC payments</string>
+
+ <string name="mfa_challenge_title">Two-factor authentication</string>
+ <string name="mfa_challenge_message">A confirmation code was sent via %1$s (%2$s). Enter the code to continue.</string>
+ <string name="mfa_challenge_code_hint">Verification code</string>
+ <string name="mfa_choose_title">Choose verification method</string>
+ <string name="mfa_challenge_invalid">Incorrect code. Please try again.</string>
+ <string name="mfa_challenge_retry">Too many attempts. A new code has been sent.</string>
+ <string name="mfa_challenge_failed">Verification failed. Please try again later.</string>
</resources>