taler-android

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

MfaUtils.kt (17369B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2026 Taler Systems S.A.
      4  *
      5  * GNU Taler is free software; you can redistribute it and/or modify it under the
      6  * terms of the GNU General Public License as published by the Free Software
      7  * Foundation; either version 3, or (at your option) any later version.
      8  *
      9  * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
     10  * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11  * A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
     12  *
     13  * You should have received a copy of the GNU General Public License along with
     14  * GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15  */
     16 
     17 package net.taler.lib.android
     18 
     19 import android.app.Dialog
     20 import android.content.Context
     21 import android.content.res.Configuration.UI_MODE_NIGHT_MASK
     22 import android.content.res.Configuration.UI_MODE_NIGHT_YES
     23 import android.graphics.Color.TRANSPARENT
     24 import android.graphics.drawable.ColorDrawable
     25 import android.view.ViewGroup.LayoutParams.MATCH_PARENT
     26 import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
     27 import androidx.lifecycle.setViewTreeLifecycleOwner
     28 import androidx.savedstate.setViewTreeSavedStateRegistryOwner
     29 import android.widget.Toast
     30 import androidx.compose.foundation.BorderStroke
     31 import androidx.compose.foundation.clickable
     32 import androidx.compose.foundation.interaction.MutableInteractionSource
     33 import androidx.compose.foundation.layout.Arrangement
     34 import androidx.compose.foundation.layout.Box
     35 import androidx.compose.foundation.layout.Column
     36 import androidx.compose.foundation.layout.ColumnScope
     37 import androidx.compose.foundation.layout.Row
     38 import androidx.compose.foundation.layout.fillMaxWidth
     39 import androidx.compose.foundation.layout.padding
     40 import androidx.compose.foundation.layout.size
     41 import androidx.compose.foundation.rememberScrollState
     42 import androidx.compose.foundation.shape.RoundedCornerShape
     43 import androidx.compose.foundation.text.BasicTextField
     44 import androidx.compose.foundation.text.KeyboardActions
     45 import androidx.compose.foundation.text.KeyboardOptions
     46 import androidx.compose.foundation.verticalScroll
     47 import androidx.compose.material3.Button
     48 import androidx.compose.material3.ColorScheme
     49 import androidx.compose.material3.MaterialTheme
     50 import androidx.compose.material3.OutlinedButton
     51 import androidx.compose.material3.Surface
     52 import androidx.compose.material3.Text
     53 import androidx.compose.material3.darkColorScheme
     54 import androidx.compose.material3.lightColorScheme
     55 import androidx.compose.runtime.Composable
     56 import androidx.compose.runtime.LaunchedEffect
     57 import androidx.compose.runtime.getValue
     58 import androidx.compose.runtime.mutableStateOf
     59 import androidx.compose.runtime.remember
     60 import androidx.compose.runtime.setValue
     61 import androidx.compose.ui.Alignment
     62 import androidx.compose.ui.Modifier
     63 import androidx.compose.ui.focus.FocusRequester
     64 import androidx.compose.ui.focus.focusRequester
     65 import androidx.compose.ui.graphics.Color
     66 import androidx.compose.ui.graphics.SolidColor
     67 import androidx.compose.ui.platform.ComposeView
     68 import androidx.compose.ui.platform.ViewCompositionStrategy
     69 import androidx.compose.ui.res.stringResource
     70 import androidx.compose.ui.semantics.contentDescription
     71 import androidx.compose.ui.semantics.semantics
     72 import androidx.compose.ui.text.TextStyle
     73 import androidx.compose.ui.text.font.FontWeight
     74 import androidx.compose.ui.text.input.ImeAction
     75 import androidx.compose.ui.text.input.KeyboardType
     76 import androidx.compose.ui.unit.dp
     77 import androidx.compose.ui.unit.sp
     78 import androidx.fragment.app.Fragment
     79 import com.google.android.material.color.MaterialColors
     80 import io.ktor.client.plugins.ClientRequestException
     81 import kotlinx.coroutines.Dispatchers
     82 import kotlinx.coroutines.suspendCancellableCoroutine
     83 import kotlinx.coroutines.withContext
     84 import net.taler.common.Challenge
     85 import net.taler.common.R
     86 import kotlin.coroutines.resume
     87 
     88 enum class ChallengeRetryDecision {
     89     Retry,
     90     Resend,
     91     Abort
     92 }
     93 
     94 class ChallengeCancelledException : Exception()
     95 
     96 suspend fun Fragment.handleChallengeResponse(
     97     challenges: List<Challenge>,
     98     combiAnd: Boolean,
     99     onRequestChallenge: suspend (challengeId: String) -> Unit,
    100     onConfirmChallenge: suspend (challengeId: String, tan: String) -> Unit,
    101 ): List<String> {
    102     if (challenges.isEmpty()) return emptyList()
    103     val challengesToSolve = if (combiAnd) {
    104         challenges
    105     } else {
    106         val selected = selectChallenge(challenges) ?: return emptyList()
    107         listOf(selected)
    108     }
    109 
    110     val solvedIds = mutableListOf<String>()
    111     for (challenge in challengesToSolve) {
    112         onRequestChallenge(challenge.challengeId)
    113 
    114         while (true) {
    115             val tan = promptForTan(challenge) ?: return emptyList()
    116             try {
    117                 onConfirmChallenge(challenge.challengeId, tan)
    118                 solvedIds.add(challenge.challengeId)
    119                 break
    120             } catch (e: Exception) {
    121                 when (handleChallengeConfirmError(e)) {
    122                     ChallengeRetryDecision.Retry -> continue
    123                     ChallengeRetryDecision.Resend -> {
    124                         onRequestChallenge(challenge.challengeId)
    125                         continue
    126                     }
    127                     ChallengeRetryDecision.Abort -> return emptyList()
    128                 }
    129             }
    130         }
    131     }
    132     return solvedIds
    133 }
    134 
    135 suspend fun Fragment.selectChallenge(challenges: List<Challenge>): Challenge? =
    136     showMfaDialog(cancelResult = null) { finish ->
    137         MfaDialogSurface {
    138             Text(
    139                 text = stringResource(R.string.mfa_choose_title),
    140                 style = MaterialTheme.typography.headlineSmall,
    141             )
    142             Column(
    143                 modifier = Modifier
    144                     .fillMaxWidth()
    145                     .verticalScroll(rememberScrollState()),
    146                 verticalArrangement = Arrangement.spacedBy(8.dp),
    147             ) {
    148                 challenges.forEach { challenge ->
    149                     Button(
    150                         onClick = { finish(challenge) },
    151                         modifier = Modifier.fillMaxWidth(),
    152                     ) {
    153                         Text("${challenge.tanChannel}: ${challenge.tanInfo}")
    154                     }
    155                 }
    156             }
    157             DialogActions(
    158                 onCancel = { finish(null) },
    159             )
    160         }
    161     }
    162 
    163 suspend fun Fragment.promptForTan(
    164     challenge: Challenge,
    165 ): String? =
    166     showMfaDialog(cancelResult = null) { finish ->
    167         var code by remember { mutableStateOf("") }
    168         var showIncompleteError by remember { mutableStateOf(false) }
    169         val submit = {
    170             val normalizedCode = normalizeMfaCode(code)
    171             if (normalizedCode == null) {
    172                 showIncompleteError = true
    173             } else {
    174                 finish(formatMfaCode(normalizedCode))
    175             }
    176         }
    177 
    178         MfaDialogSurface {
    179             Text(
    180                 text = stringResource(R.string.mfa_challenge_title),
    181                 style = MaterialTheme.typography.headlineSmall,
    182             )
    183             Text(
    184                 text = stringResource(
    185                     R.string.mfa_challenge_message,
    186                     challenge.tanChannel.name,
    187                     challenge.tanInfo,
    188                 ),
    189                 style = MaterialTheme.typography.bodyMedium,
    190                 color = MaterialTheme.colorScheme.onSurfaceVariant,
    191             )
    192             MfaCodeInput(
    193                 code = code,
    194                 isError = showIncompleteError,
    195                 onCodeChanged = {
    196                     code = it
    197                     showIncompleteError = false
    198                 },
    199                 onSubmit = submit,
    200             )
    201             if (showIncompleteError) {
    202                 Text(
    203                     text = stringResource(R.string.mfa_challenge_code_incomplete),
    204                     style = MaterialTheme.typography.bodySmall,
    205                     color = MaterialTheme.colorScheme.error,
    206                 )
    207             }
    208             DialogActions(
    209                 onCancel = { finish(null) },
    210                 onConfirm = submit,
    211             )
    212         }
    213     }
    214 
    215 @Composable
    216 private fun MfaDialogSurface(
    217     content: @Composable ColumnScope.() -> Unit,
    218 ) {
    219     Surface(
    220         modifier = Modifier
    221             .fillMaxWidth()
    222             .padding(24.dp),
    223         shape = RoundedCornerShape(28.dp),
    224         color = MaterialTheme.colorScheme.surface,
    225         tonalElevation = 6.dp,
    226         shadowElevation = 6.dp,
    227     ) {
    228         Column(
    229             modifier = Modifier.padding(24.dp),
    230             verticalArrangement = Arrangement.spacedBy(20.dp),
    231             content = content,
    232         )
    233     }
    234 }
    235 
    236 @Composable
    237 private fun MfaCodeInput(
    238     code: String,
    239     isError: Boolean,
    240     onCodeChanged: (String) -> Unit,
    241     onSubmit: () -> Unit,
    242 ) {
    243     val focusRequester = remember { FocusRequester() }
    244     val interactionSource = remember { MutableInteractionSource() }
    245     val codeHint = stringResource(R.string.mfa_challenge_code_hint)
    246 
    247     LaunchedEffect(Unit) {
    248         focusRequester.requestFocus()
    249     }
    250 
    251     BasicTextField(
    252         value = code,
    253         onValueChange = { value ->
    254             onCodeChanged(value.filter(Char::isDigit).take(MFA_CODE_DIGITS))
    255         },
    256         modifier = Modifier
    257             .fillMaxWidth()
    258             .focusRequester(focusRequester)
    259             .semantics { contentDescription = codeHint },
    260         textStyle = TextStyle(color = Color.Transparent),
    261         cursorBrush = SolidColor(Color.Transparent),
    262         singleLine = true,
    263         keyboardOptions = KeyboardOptions(
    264             keyboardType = KeyboardType.NumberPassword,
    265             imeAction = ImeAction.Done,
    266         ),
    267         keyboardActions = KeyboardActions(onDone = { onSubmit() }),
    268         interactionSource = interactionSource,
    269         decorationBox = {
    270             Row(
    271                 modifier = Modifier
    272                     .fillMaxWidth()
    273                     .clickable(
    274                         interactionSource = interactionSource,
    275                         indication = null,
    276                     ) { focusRequester.requestFocus() },
    277                 horizontalArrangement = Arrangement.Center,
    278                 verticalAlignment = Alignment.CenterVertically,
    279             ) {
    280                 repeat(MFA_CODE_DIGITS) { index ->
    281                     if (index == MFA_CODE_DIGITS / 2) {
    282                         Text(
    283                             text = "-",
    284                             modifier = Modifier.padding(horizontal = 3.dp),
    285                             color = MaterialTheme.colorScheme.primary,
    286                             fontSize = 22.sp,
    287                             fontWeight = FontWeight.Bold,
    288                         )
    289                     }
    290                     MfaCodeDigit(
    291                         digit = code.getOrNull(index),
    292                         isActive = index == code.length.coerceAtMost(MFA_CODE_DIGITS - 1),
    293                         isError = isError,
    294                     )
    295                 }
    296             }
    297         },
    298     )
    299 }
    300 
    301 @Composable
    302 private fun MfaCodeDigit(
    303     digit: Char?,
    304     isActive: Boolean,
    305     isError: Boolean,
    306 ) {
    307     val borderColor = when {
    308         isError -> MaterialTheme.colorScheme.error
    309         isActive -> MaterialTheme.colorScheme.primary
    310         else -> MaterialTheme.colorScheme.outline
    311     }
    312     Box(
    313         modifier = Modifier
    314             .padding(horizontal = 1.dp)
    315             .size(width = 30.dp, height = 52.dp),
    316         contentAlignment = Alignment.Center,
    317     ) {
    318         Surface(
    319             modifier = Modifier.matchParentSize(),
    320             shape = RoundedCornerShape(6.dp),
    321             color = MaterialTheme.colorScheme.surface,
    322             border = BorderStroke(if (isActive) 2.dp else 1.dp, borderColor),
    323         ) {}
    324         Text(
    325             text = digit?.toString().orEmpty(),
    326             color = MaterialTheme.colorScheme.onSurface,
    327             fontSize = 20.sp,
    328             fontWeight = FontWeight.Bold,
    329         )
    330     }
    331 }
    332 
    333 @Composable
    334 private fun DialogActions(
    335     onCancel: () -> Unit,
    336     onConfirm: (() -> Unit)? = null,
    337 ) {
    338     Row(
    339         modifier = Modifier.fillMaxWidth(),
    340         horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
    341     ) {
    342         OutlinedButton(onClick = onCancel) {
    343             Text(stringResource(android.R.string.cancel))
    344         }
    345         if (onConfirm != null) {
    346             Button(onClick = onConfirm) {
    347                 Text(stringResource(android.R.string.ok))
    348             }
    349         }
    350     }
    351 }
    352 
    353 private suspend fun <T> Fragment.showMfaDialog(
    354     cancelResult: T,
    355     content: @Composable ((T) -> Unit) -> Unit,
    356 ): T = withContext(Dispatchers.Main) {
    357     suspendCancellableCoroutine { continuation ->
    358         val dialog = Dialog(requireContext())
    359         var completed = false
    360 
    361         fun finish(result: T) {
    362             if (completed) return
    363             completed = true
    364             if (continuation.isActive) continuation.resume(result)
    365             dialog.dismiss()
    366         }
    367 
    368         val composeView = ComposeView(requireContext()).apply {
    369             setViewTreeLifecycleOwner(viewLifecycleOwner)
    370             setViewTreeSavedStateRegistryOwner(this@showMfaDialog)
    371             setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
    372             setContent {
    373                 MaterialTheme(colorScheme = mfaColorScheme(context)) {
    374                     content(::finish)
    375                 }
    376             }
    377         }
    378         dialog.setContentView(composeView)
    379         dialog.setCanceledOnTouchOutside(true)
    380         dialog.setOnCancelListener { finish(cancelResult) }
    381         dialog.show()
    382         dialog.window?.setBackgroundDrawable(ColorDrawable(TRANSPARENT))
    383         dialog.window?.setLayout(MATCH_PARENT, WRAP_CONTENT)
    384 
    385         continuation.invokeOnCancellation {
    386             composeView.post { dialog.dismiss() }
    387         }
    388     }
    389 }
    390 
    391 suspend fun Fragment.handleChallengeConfirmError(e: Exception): ChallengeRetryDecision =
    392     withContext(Dispatchers.Main) {
    393         if (e is ClientRequestException) {
    394             when (e.response.status.value) {
    395                 409 -> {
    396                     Toast.makeText(
    397                         requireContext(),
    398                         R.string.mfa_challenge_invalid,
    399                         Toast.LENGTH_LONG,
    400                     ).show()
    401                     return@withContext ChallengeRetryDecision.Retry
    402                 }
    403                 429 -> {
    404                     Toast.makeText(
    405                         requireContext(),
    406                         R.string.mfa_challenge_retry,
    407                         Toast.LENGTH_LONG,
    408                     ).show()
    409                     return@withContext ChallengeRetryDecision.Resend
    410                 }
    411             }
    412         }
    413         Toast.makeText(
    414             requireContext(),
    415             R.string.mfa_challenge_failed,
    416             Toast.LENGTH_LONG,
    417         ).show()
    418         ChallengeRetryDecision.Abort
    419     }
    420 
    421 internal fun normalizeMfaCode(value: String): String? {
    422     val digits = value.filter(Char::isDigit)
    423     return digits.takeIf { it.length == MFA_CODE_DIGITS }
    424 }
    425 
    426 internal fun formatMfaCode(value: String): String =
    427     value.take(MFA_CODE_DIGITS / 2) + "-" + value.drop(MFA_CODE_DIGITS / 2)
    428 
    429 private fun mfaColorScheme(context: Context): ColorScheme {
    430     val isDark = context.resources.configuration.uiMode and UI_MODE_NIGHT_MASK ==
    431         UI_MODE_NIGHT_YES
    432     val primary = context.materialColor(androidx.appcompat.R.attr.colorPrimary)
    433     val onPrimary = context.materialColor(com.google.android.material.R.attr.colorOnPrimary)
    434     val primaryContainer = context.materialColor(
    435         com.google.android.material.R.attr.colorPrimaryContainer,
    436     )
    437     val onPrimaryContainer = context.materialColor(
    438         com.google.android.material.R.attr.colorOnPrimaryContainer,
    439     )
    440     val surface = context.materialColor(com.google.android.material.R.attr.colorSurface)
    441     val onSurface = context.materialColor(com.google.android.material.R.attr.colorOnSurface)
    442     val surfaceVariant = context.materialColor(
    443         com.google.android.material.R.attr.colorSurfaceVariant,
    444     )
    445     val onSurfaceVariant = context.materialColor(
    446         com.google.android.material.R.attr.colorOnSurfaceVariant,
    447     )
    448     val outline = context.materialColor(com.google.android.material.R.attr.colorOutline)
    449 
    450     return if (isDark) {
    451         darkColorScheme(
    452             primary = primary,
    453             onPrimary = onPrimary,
    454             primaryContainer = primaryContainer,
    455             onPrimaryContainer = onPrimaryContainer,
    456             surface = surface,
    457             onSurface = onSurface,
    458             surfaceVariant = surfaceVariant,
    459             onSurfaceVariant = onSurfaceVariant,
    460             outline = outline,
    461         )
    462     } else {
    463         lightColorScheme(
    464             primary = primary,
    465             onPrimary = onPrimary,
    466             primaryContainer = primaryContainer,
    467             onPrimaryContainer = onPrimaryContainer,
    468             surface = surface,
    469             onSurface = onSurface,
    470             surfaceVariant = surfaceVariant,
    471             onSurfaceVariant = onSurfaceVariant,
    472             outline = outline,
    473         )
    474     }
    475 }
    476 
    477 private fun Context.materialColor(attr: Int): Color =
    478     Color(MaterialColors.getColor(this, attr, android.graphics.Color.MAGENTA))
    479 
    480 private const val MFA_CODE_DIGITS = 8