commit 702b4e48b1012dcbbcef4cf23738d88ed82b6dbb
parent ae24e23b5c1607523e4fdccff11a1c75d90094ed
Author: Bohdan Potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date: Sun, 7 Jun 2026 19:42:23 +0200
[merchant-terminal] #0011425 mfa + clear order button fix + settings password eye
Diffstat:
8 files changed, 376 insertions(+), 382 deletions(-)
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt
@@ -315,7 +315,7 @@ private fun MerchantTerminalApp(
val currentOrder = currentOrderState?.value
val restartState by currentOrderLive?.restartState?.observeAsState(DISABLED) ?: remember { androidx.compose.runtime.mutableStateOf(DISABLED) }
val clearOrderEnabled =
- restartState == UNDO || currentOrder?.products?.isNotEmpty() == true
+ restartState != DISABLED || currentOrder?.products?.isNotEmpty() == true
val hasPreviousOrder = currentOrderId?.let { viewModel.orderManager.hasPreviousOrder(it) } ?: false
val hasNextOrder by currentOrderId?.let { viewModel.orderManager.hasNextOrder(it).observeAsState(false) }
?: remember { androidx.compose.runtime.mutableStateOf(false) }
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFragment.kt
@@ -101,6 +101,15 @@ import net.taler.merchantpos.showPosError
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.runtime.remember
private enum class ConfigMode { Manual, Qr }
@@ -585,12 +594,25 @@ private fun ManualConfigScreen(
)
}
item {
+ val interactionSource = remember { MutableInteractionSource() }
+ val pressed by interactionSource.collectIsPressedAsState()
OutlinedTextField(
value = token,
onValueChange = onTokenChanged,
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.config_password)) },
- visualTransformation = PasswordVisualTransformation(),
+ visualTransformation = if (pressed) VisualTransformation.None else PasswordVisualTransformation(),
+ trailingIcon = {
+ IconButton(
+ onClick = {},
+ interactionSource = interactionSource,
+ ) {
+ Icon(
+ imageVector = if (pressed) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
+ contentDescription = null,
+ )
+ }
+ },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
diff --git a/merchant-terminal/src/main/res/values/styles.xml b/merchant-terminal/src/main/res/values/styles.xml
@@ -1,23 +1,5 @@
<resources xmlns:tools="http://schemas.android.com/tools">
- <style name="Widget.Taler.MfaCodeDigit" parent="@android:style/Widget.EditText">
- <item name="android:layout_width">30dp</item>
- <item name="android:layout_height">52dp</item>
- <item name="android:layout_marginHorizontal">1dp</item>
- <item name="android:background">@drawable/mfa_code_digit_background</item>
- <item name="android:gravity">center</item>
- <item name="android:imeOptions">actionNext</item>
- <item name="android:inputType">number</item>
- <item name="android:maxLength">1</item>
- <item name="android:padding">0dp</item>
- <item name="android:selectAllOnFocus">true</item>
- <item name="android:singleLine">true</item>
- <item name="android:textColor">?attr/colorOnSurface</item>
- <item name="android:textColorHint">?attr/colorOnSurfaceVariant</item>
- <item name="android:textSize">20sp</item>
- <item name="android:textStyle">bold</item>
- </style>
-
<style name="AppTheme.Light" parent="Theme.Material3.Light">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorOnPrimary">@color/colorOnPrimary</item>
diff --git a/taler-kotlin-android/src/main/java/net/taler/lib/android/MfaUtils.kt b/taler-kotlin-android/src/main/java/net/taler/lib/android/MfaUtils.kt
@@ -16,32 +16,73 @@
package net.taler.lib.android
-import android.content.res.ColorStateList
-import android.content.DialogInterface
-import android.text.Editable
-import android.text.InputFilter
-import android.text.TextWatcher
-import android.view.KeyEvent
-import android.view.LayoutInflater
-import android.view.View.GONE
-import android.view.View.VISIBLE
-import android.view.inputmethod.EditorInfo
-import android.widget.Button
-import android.widget.EditText
-import android.widget.LinearLayout
-import android.widget.TextView
+import android.app.Dialog
+import android.content.Context
+import android.content.res.Configuration.UI_MODE_NIGHT_MASK
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+import android.graphics.Color.TRANSPARENT
+import android.graphics.drawable.ColorDrawable
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import android.widget.Toast
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import androidx.fragment.app.Fragment
-import androidx.core.content.res.use
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.google.android.material.button.MaterialButton
+import com.google.android.material.color.MaterialColors
import io.ktor.client.plugins.ClientRequestException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import net.taler.common.Challenge
import net.taler.common.R
-import android.graphics.drawable.GradientDrawable
import kotlin.coroutines.resume
enum class ChallengeRetryDecision {
@@ -92,106 +133,258 @@ suspend fun Fragment.handleChallengeResponse(
}
suspend fun Fragment.selectChallenge(challenges: List<Challenge>): Challenge? =
- withContext(Dispatchers.Main) {
- suspendCancellableCoroutine { cont ->
- val buttonContainer = LinearLayout(requireContext()).apply {
- orientation = LinearLayout.VERTICAL
- val density = resources.displayMetrics.density
- val horizontalPadding = (24 * density).toInt()
- val verticalPadding = (8 * density).toInt()
- setPadding(horizontalPadding, verticalPadding, horizontalPadding, 0)
- }
- var dialog: androidx.appcompat.app.AlertDialog? = null
- challenges.forEach { challenge ->
- val button = MaterialButton(requireContext()).apply {
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT,
- LinearLayout.LayoutParams.WRAP_CONTENT,
- ).apply {
- bottomMargin = dp(8)
- }
- text = "${challenge.tanChannel}: ${challenge.tanInfo}"
- backgroundTintList = colorStateListFromAttr(androidx.appcompat.R.attr.colorPrimary)
- setTextColor(colorFromAttr(com.google.android.material.R.attr.colorOnPrimary))
- isAllCaps = false
- cornerRadius = dp(10)
- setOnClickListener {
- if (cont.isActive) cont.resume(challenge)
- dialog?.dismiss()
+ showMfaDialog(cancelResult = null) { finish ->
+ MfaDialogSurface {
+ Text(
+ text = stringResource(R.string.mfa_choose_title),
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ challenges.forEach { challenge ->
+ Button(
+ onClick = { finish(challenge) },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("${challenge.tanChannel}: ${challenge.tanInfo}")
}
}
- buttonContainer.addView(button)
}
- dialog = MaterialAlertDialogBuilder(requireContext())
- .setTitle(R.string.mfa_choose_title)
- .setView(buttonContainer)
- .setNegativeButton(android.R.string.cancel) { _, _ ->
- if (cont.isActive) cont.resume(null)
- }
- .setOnCancelListener {
- if (cont.isActive) cont.resume(null)
- }
- .show()
- styleMfaDialogActions(dialog)
+ DialogActions(
+ onCancel = { finish(null) },
+ )
}
}
suspend fun Fragment.promptForTan(challenge: Challenge): String? =
- withContext(Dispatchers.Main) {
- suspendCancellableCoroutine { cont ->
- val message = getString(
- R.string.mfa_challenge_message,
- challenge.tanChannel.name,
- challenge.tanInfo
+ showMfaDialog(cancelResult = null) { finish ->
+ var code by remember { mutableStateOf("") }
+ var showIncompleteError by remember { mutableStateOf(false) }
+ val submit = {
+ val normalizedCode = normalizeMfaCode(code)
+ if (normalizedCode == null) {
+ showIncompleteError = true
+ } else {
+ finish(formatMfaCode(normalizedCode))
+ }
+ }
+
+ MfaDialogSurface {
+ Text(
+ text = stringResource(R.string.mfa_challenge_title),
+ style = MaterialTheme.typography.headlineSmall,
)
- val dialogView = LayoutInflater.from(requireContext()).inflate(
- R.layout.dialog_mfa_challenge,
- null,
- false
+ Text(
+ text = stringResource(
+ R.string.mfa_challenge_message,
+ challenge.tanChannel.name,
+ challenge.tanInfo,
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
- val messageView = dialogView.findViewById<TextView>(R.id.mfaMessageView)
- val errorView = dialogView.findViewById<TextView>(R.id.mfaCodeErrorView)
- val inputs = listOf(
- dialogView.findViewById<EditText>(R.id.mfaCodeDigit1),
- dialogView.findViewById<EditText>(R.id.mfaCodeDigit2),
- dialogView.findViewById<EditText>(R.id.mfaCodeDigit3),
- dialogView.findViewById<EditText>(R.id.mfaCodeDigit4),
- dialogView.findViewById<EditText>(R.id.mfaCodeDigit5),
- dialogView.findViewById<EditText>(R.id.mfaCodeDigit6),
- dialogView.findViewById<EditText>(R.id.mfaCodeDigit7),
- dialogView.findViewById<EditText>(R.id.mfaCodeDigit8),
+ MfaCodeInput(
+ code = code,
+ isError = showIncompleteError,
+ onCodeChanged = {
+ code = it
+ showIncompleteError = false
+ },
+ onSubmit = submit,
)
- messageView.text = message
- errorView.visibility = GONE
- val dialog = MaterialAlertDialogBuilder(requireContext())
- .setTitle(R.string.mfa_challenge_title)
- .setView(dialogView)
- .setPositiveButton(android.R.string.ok, null)
- .setNegativeButton(android.R.string.cancel) { _, _ ->
- cont.resume(null)
- }
- .setOnCancelListener { cont.resume(null) }
- .show()
- fun submitCode(): Boolean {
- val code = collectMfaCode(inputs)
- if (code == null) {
- errorView.text = getString(R.string.mfa_challenge_code_incomplete)
- errorView.visibility = VISIBLE
- return false
+ if (showIncompleteError) {
+ Text(
+ text = stringResource(R.string.mfa_challenge_code_incomplete),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ DialogActions(
+ onCancel = { finish(null) },
+ onConfirm = submit,
+ )
+ }
+ }
+
+@Composable
+private fun MfaDialogSurface(
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ shape = RoundedCornerShape(28.dp),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 6.dp,
+ shadowElevation = 6.dp,
+ ) {
+ Column(
+ modifier = Modifier.padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(20.dp),
+ content = content,
+ )
+ }
+}
+
+@Composable
+private fun MfaCodeInput(
+ code: String,
+ isError: Boolean,
+ onCodeChanged: (String) -> Unit,
+ onSubmit: () -> Unit,
+) {
+ val focusRequester = remember { FocusRequester() }
+ val interactionSource = remember { MutableInteractionSource() }
+ val codeHint = stringResource(R.string.mfa_challenge_code_hint)
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+
+ BasicTextField(
+ value = code,
+ onValueChange = { value ->
+ onCodeChanged(value.filter(Char::isDigit).take(MFA_CODE_DIGITS))
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester)
+ .semantics { contentDescription = codeHint },
+ textStyle = TextStyle(color = Color.Transparent),
+ cursorBrush = SolidColor(Color.Transparent),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.NumberPassword,
+ imeAction = ImeAction.Done,
+ ),
+ keyboardActions = KeyboardActions(onDone = { onSubmit() }),
+ interactionSource = interactionSource,
+ decorationBox = {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(
+ interactionSource = interactionSource,
+ indication = null,
+ ) { focusRequester.requestFocus() },
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ repeat(MFA_CODE_DIGITS) { index ->
+ if (index == MFA_CODE_DIGITS / 2) {
+ Text(
+ text = "-",
+ modifier = Modifier.padding(horizontal = 3.dp),
+ color = MaterialTheme.colorScheme.primary,
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ MfaCodeDigit(
+ digit = code.getOrNull(index),
+ isActive = index == code.length.coerceAtMost(MFA_CODE_DIGITS - 1),
+ isError = isError,
+ )
}
- errorView.visibility = GONE
- cont.resume(code)
- dialog.dismiss()
- return true
}
- setupMfaCodeInputs(inputs, errorView, ::submitCode)
- dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener {
- submitCode()
+ },
+ )
+}
+
+@Composable
+private fun MfaCodeDigit(
+ digit: Char?,
+ isActive: Boolean,
+ isError: Boolean,
+) {
+ val borderColor = when {
+ isError -> MaterialTheme.colorScheme.error
+ isActive -> MaterialTheme.colorScheme.primary
+ else -> MaterialTheme.colorScheme.outline
+ }
+ Box(
+ modifier = Modifier
+ .padding(horizontal = 1.dp)
+ .size(width = 30.dp, height = 52.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Surface(
+ modifier = Modifier.matchParentSize(),
+ shape = RoundedCornerShape(6.dp),
+ color = MaterialTheme.colorScheme.surface,
+ border = BorderStroke(if (isActive) 2.dp else 1.dp, borderColor),
+ ) {}
+ Text(
+ text = digit?.toString().orEmpty(),
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+}
+
+@Composable
+private fun DialogActions(
+ onCancel: () -> Unit,
+ onConfirm: (() -> Unit)? = null,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
+ ) {
+ OutlinedButton(onClick = onCancel) {
+ Text(stringResource(android.R.string.cancel))
+ }
+ if (onConfirm != null) {
+ Button(onClick = onConfirm) {
+ Text(stringResource(android.R.string.ok))
}
- styleMfaDialogActions(dialog)
- inputs.firstOrNull()?.requestFocus()
}
}
+}
+
+private suspend fun <T> Fragment.showMfaDialog(
+ cancelResult: T,
+ content: @Composable ((T) -> Unit) -> Unit,
+): T = withContext(Dispatchers.Main) {
+ suspendCancellableCoroutine { continuation ->
+ val dialog = Dialog(requireContext())
+ var completed = false
+
+ fun finish(result: T) {
+ if (completed) return
+ completed = true
+ if (continuation.isActive) continuation.resume(result)
+ dialog.dismiss()
+ }
+
+ val composeView = ComposeView(requireContext()).apply {
+ setViewTreeLifecycleOwner(viewLifecycleOwner)
+ setViewTreeSavedStateRegistryOwner(this@showMfaDialog)
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
+ setContent {
+ MaterialTheme(colorScheme = mfaColorScheme(context)) {
+ content(::finish)
+ }
+ }
+ }
+ dialog.setContentView(composeView)
+ dialog.setCanceledOnTouchOutside(true)
+ dialog.setOnCancelListener { finish(cancelResult) }
+ dialog.show()
+ dialog.window?.setBackgroundDrawable(ColorDrawable(TRANSPARENT))
+ dialog.window?.setLayout(MATCH_PARENT, WRAP_CONTENT)
+
+ continuation.invokeOnCancellation {
+ composeView.post { dialog.dismiss() }
+ }
+ }
+}
suspend fun Fragment.handleChallengeConfirmError(e: Exception): ChallengeRetryDecision =
withContext(Dispatchers.Main) {
@@ -201,7 +394,7 @@ suspend fun Fragment.handleChallengeConfirmError(e: Exception): ChallengeRetryDe
Toast.makeText(
requireContext(),
R.string.mfa_challenge_invalid,
- Toast.LENGTH_LONG
+ Toast.LENGTH_LONG,
).show()
return@withContext ChallengeRetryDecision.Retry
}
@@ -209,7 +402,7 @@ suspend fun Fragment.handleChallengeConfirmError(e: Exception): ChallengeRetryDe
Toast.makeText(
requireContext(),
R.string.mfa_challenge_retry,
- Toast.LENGTH_LONG
+ Toast.LENGTH_LONG,
).show()
return@withContext ChallengeRetryDecision.Resend
}
@@ -218,143 +411,68 @@ suspend fun Fragment.handleChallengeConfirmError(e: Exception): ChallengeRetryDe
Toast.makeText(
requireContext(),
R.string.mfa_challenge_failed,
- Toast.LENGTH_LONG
+ Toast.LENGTH_LONG,
).show()
ChallengeRetryDecision.Abort
}
-private fun setupMfaCodeInputs(
- inputs: List<EditText>,
- errorView: TextView,
- onSubmit: () -> Boolean,
-) {
- var updatingInputs = false
- inputs.forEachIndexed { index, input ->
- input.filters = input.filters
- .filterNot { it is InputFilter.LengthFilter }
- .toTypedArray()
- input.imeOptions = if (index == inputs.lastIndex) {
- EditorInfo.IME_ACTION_DONE
- } else {
- EditorInfo.IME_ACTION_NEXT
- }
- input.addTextChangedListener(object : TextWatcher {
- override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
- override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
-
- override fun afterTextChanged(s: Editable?) {
- if (updatingInputs) return
- errorView.visibility = GONE
- val value = s?.toString().orEmpty()
- if (value.length > 1) {
- val pastedCode = normalizeMfaCode(value)
- updatingInputs = true
- try {
- if (pastedCode == null) {
- input.text?.clear()
- } else {
- inputs.forEachIndexed { digitIndex, digitInput ->
- digitInput.setText(pastedCode[digitIndex].toString())
- }
- }
- } finally {
- updatingInputs = false
- }
- if (pastedCode != null) {
- inputs.last().apply {
- requestFocus()
- setSelection(text?.length ?: 0)
- }
- }
- } else if (value.length == 1 && index < inputs.lastIndex) {
- inputs[index + 1].requestFocus()
- }
- }
- })
- input.setOnKeyListener { _, keyCode, event ->
- if (keyCode == KeyEvent.KEYCODE_DEL &&
- event.action == KeyEvent.ACTION_DOWN &&
- input.text.isNullOrEmpty() &&
- index > 0
- ) {
- inputs[index - 1].apply {
- requestFocus()
- text?.clear()
- }
- true
- } else {
- false
- }
- }
- input.setOnEditorActionListener { _, actionId, event ->
- val isEnter = event?.keyCode == KeyEvent.KEYCODE_ENTER &&
- event.action == KeyEvent.ACTION_UP
- when {
- actionId == EditorInfo.IME_ACTION_NEXT && index < inputs.lastIndex -> {
- inputs[index + 1].requestFocus()
- true
- }
- actionId == EditorInfo.IME_ACTION_NEXT ||
- actionId == EditorInfo.IME_ACTION_DONE ||
- isEnter -> onSubmit()
- else -> false
- }
- }
- }
-}
-
internal fun normalizeMfaCode(value: String): String? {
val digits = value.filter(Char::isDigit)
- return digits.takeIf { it.length == 8 }
+ return digits.takeIf { it.length == MFA_CODE_DIGITS }
}
-private fun collectMfaCode(inputs: List<EditText>): String? {
- val digits = inputs.map { it.text?.toString().orEmpty() }
- if (digits.any { it.length != 1 }) return null
- return digits.take(4).joinToString("") + "-" + digits.drop(4).joinToString("")
-}
+internal fun formatMfaCode(value: String): String =
+ value.take(MFA_CODE_DIGITS / 2) + "-" + value.drop(MFA_CODE_DIGITS / 2)
-private fun Fragment.styleMfaDialogActions(dialog: androidx.appcompat.app.AlertDialog) {
- styleDialogActionButton(
- button = dialog.getButton(DialogInterface.BUTTON_POSITIVE),
- backgroundAttr = androidx.appcompat.R.attr.colorPrimary,
- textAttr = com.google.android.material.R.attr.colorOnPrimary,
+private fun mfaColorScheme(context: Context): ColorScheme {
+ val isDark = context.resources.configuration.uiMode and UI_MODE_NIGHT_MASK ==
+ UI_MODE_NIGHT_YES
+ val primary = context.materialColor(androidx.appcompat.R.attr.colorPrimary)
+ val onPrimary = context.materialColor(com.google.android.material.R.attr.colorOnPrimary)
+ val primaryContainer = context.materialColor(
+ com.google.android.material.R.attr.colorPrimaryContainer,
)
- styleDialogActionButton(
- button = dialog.getButton(DialogInterface.BUTTON_NEGATIVE),
- backgroundAttr = com.google.android.material.R.attr.colorPrimaryContainer,
- textAttr = com.google.android.material.R.attr.colorOnPrimaryContainer,
+ val onPrimaryContainer = context.materialColor(
+ com.google.android.material.R.attr.colorOnPrimaryContainer,
)
-}
-
-private fun Fragment.styleDialogActionButton(
- button: Button?,
- backgroundAttr: Int,
- textAttr: Int,
-) {
- button ?: return
- val backgroundColor = colorFromAttr(backgroundAttr)
- button.isAllCaps = false
- button.minHeight = dp(40)
- button.minWidth = dp(96)
- button.setPadding(dp(16), 0, dp(16), 0)
- button.setTextColor(colorFromAttr(textAttr))
- button.backgroundTintList = ColorStateList.valueOf(backgroundColor)
- button.background = GradientDrawable().apply {
- shape = GradientDrawable.RECTANGLE
- cornerRadius = dp(8).toFloat()
- setColor(backgroundColor)
- }
-}
+ val surface = context.materialColor(com.google.android.material.R.attr.colorSurface)
+ val onSurface = context.materialColor(com.google.android.material.R.attr.colorOnSurface)
+ val surfaceVariant = context.materialColor(
+ com.google.android.material.R.attr.colorSurfaceVariant,
+ )
+ val onSurfaceVariant = context.materialColor(
+ com.google.android.material.R.attr.colorOnSurfaceVariant,
+ )
+ val outline = context.materialColor(com.google.android.material.R.attr.colorOutline)
-private fun Fragment.colorFromAttr(attr: Int): Int {
- return requireContext().obtainStyledAttributes(intArrayOf(attr)).use {
- it.getColor(0, 0)
+ return if (isDark) {
+ darkColorScheme(
+ primary = primary,
+ onPrimary = onPrimary,
+ primaryContainer = primaryContainer,
+ onPrimaryContainer = onPrimaryContainer,
+ surface = surface,
+ onSurface = onSurface,
+ surfaceVariant = surfaceVariant,
+ onSurfaceVariant = onSurfaceVariant,
+ outline = outline,
+ )
+ } else {
+ lightColorScheme(
+ primary = primary,
+ onPrimary = onPrimary,
+ primaryContainer = primaryContainer,
+ onPrimaryContainer = onPrimaryContainer,
+ surface = surface,
+ onSurface = onSurface,
+ surfaceVariant = surfaceVariant,
+ onSurfaceVariant = onSurfaceVariant,
+ outline = outline,
+ )
}
}
-private fun Fragment.colorStateListFromAttr(attr: Int): ColorStateList {
- return ColorStateList.valueOf(colorFromAttr(attr))
-}
+private fun Context.materialColor(attr: Int): Color =
+ Color(MaterialColors.getColor(this, attr, android.graphics.Color.MAGENTA))
-private fun Fragment.dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
+private const val MFA_CODE_DIGITS = 8
diff --git a/taler-kotlin-android/src/main/res/drawable/mfa_code_digit_background.xml b/taler-kotlin-android/src/main/res/drawable/mfa_code_digit_background.xml
@@ -1,21 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<selector xmlns:android="http://schemas.android.com/apk/res/android">
- <item android:state_focused="true">
- <shape android:shape="rectangle">
- <solid android:color="?attr/colorSurface" />
- <stroke
- android:width="2dp"
- android:color="?attr/colorPrimary" />
- <corners android:radius="6dp" />
- </shape>
- </item>
- <item>
- <shape android:shape="rectangle">
- <solid android:color="?attr/colorSurface" />
- <stroke
- android:width="1dp"
- android:color="?attr/colorOutline" />
- <corners android:radius="6dp" />
- </shape>
- </item>
-</selector>
diff --git a/taler-kotlin-android/src/main/res/layout/dialog_mfa_challenge.xml b/taler-kotlin-android/src/main/res/layout/dialog_mfa_challenge.xml
@@ -1,93 +0,0 @@
-<?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="16dp"
- android:paddingTop="16dp"
- android:paddingEnd="16dp"
- android:paddingBottom="8dp">
-
- <TextView
- android:id="@+id/mfaMessageView"
- style="@style/TextAppearance.MaterialComponents.Body2"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginBottom="16dp"
- android:textColor="?attr/colorOnSurface" />
-
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:gravity="center"
- android:orientation="horizontal">
-
- <EditText
- android:id="@+id/mfaCodeDigit1"
- style="@style/Widget.Taler.MfaCodeDigit" />
-
- <EditText
- android:id="@+id/mfaCodeDigit2"
- style="@style/Widget.Taler.MfaCodeDigit" />
-
- <EditText
- android:id="@+id/mfaCodeDigit3"
- style="@style/Widget.Taler.MfaCodeDigit" />
-
- <EditText
- android:id="@+id/mfaCodeDigit4"
- style="@style/Widget.Taler.MfaCodeDigit" />
-
- <TextView
- style="@style/TextAppearance.Material3.TitleLarge"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginHorizontal="4dp"
- android:text="-"
- android:textColor="?attr/colorPrimary" />
-
- <EditText
- android:id="@+id/mfaCodeDigit5"
- style="@style/Widget.Taler.MfaCodeDigit" />
-
- <EditText
- android:id="@+id/mfaCodeDigit6"
- style="@style/Widget.Taler.MfaCodeDigit" />
-
- <EditText
- android:id="@+id/mfaCodeDigit7"
- style="@style/Widget.Taler.MfaCodeDigit" />
-
- <EditText
- android:id="@+id/mfaCodeDigit8"
- style="@style/Widget.Taler.MfaCodeDigit" />
- </LinearLayout>
-
- <TextView
- android:id="@+id/mfaCodeErrorView"
- style="@style/TextAppearance.Material3.BodySmall"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginTop="6dp"
- android:textColor="?attr/colorError"
- android:visibility="gone" />
-
-</LinearLayout>
diff --git a/taler-kotlin-android/src/main/res/values/styles.xml b/taler-kotlin-android/src/main/res/values/styles.xml
@@ -16,25 +16,6 @@
<resources>
- <style name="Widget.Taler.MfaCodeDigit" parent="@android:style/Widget.EditText">
- <item name="android:layout_width">30dp</item>
- <item name="android:layout_height">52dp</item>
- <item name="android:layout_marginStart">1dp</item>
- <item name="android:layout_marginEnd">1dp</item>
- <item name="android:background">@drawable/mfa_code_digit_background</item>
- <item name="android:gravity">center</item>
- <item name="android:imeOptions">actionNext</item>
- <item name="android:inputType">number</item>
- <item name="android:maxLength">1</item>
- <item name="android:padding">0dp</item>
- <item name="android:selectAllOnFocus">true</item>
- <item name="android:singleLine">true</item>
- <item name="android:textColor">?attr/colorOnSurface</item>
- <item name="android:textColorHint">?attr/colorOnSurfaceVariant</item>
- <item name="android:textSize">20sp</item>
- <item name="android:textStyle">bold</item>
- </style>
-
<style name="ErrorBottomSheet" parent="Widget.MaterialComponents.BottomSheet.Modal">
<item name="behavior_peekHeight">@dimen/bottom_sheet_peek_height</item>
</style>
diff --git a/taler-kotlin-android/src/test/java/net/taler/lib/android/MfaUtilsTest.kt b/taler-kotlin-android/src/test/java/net/taler/lib/android/MfaUtilsTest.kt
@@ -36,4 +36,9 @@ class MfaUtilsTest {
assertNull(normalizeMfaCode("123456789"))
assertNull(normalizeMfaCode("no code"))
}
+
+ @Test
+ fun formatsCodeForChallengeConfirmation() {
+ assertEquals("1234-5678", formatMfaCode("12345678"))
+ }
}