taler-android

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

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:
Mcashier/build.gradle | 2+-
Mcashier/src/main/java/net/taler/cashier/HttpHelper.kt | 16+++++++++++-----
Mcashier/src/main/java/net/taler/cashier/config/Config.kt | 156++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcashier/src/main/java/net/taler/cashier/config/ConfigFragment.kt | 177++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcashier/src/main/java/net/taler/cashier/config/ConfigManager.kt | 169++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Acashier/src/main/res/layout/dialog_mfa_challenge.xml | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcashier/src/main/res/values/strings.xml | 8++++++++
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>