ConfigDraftViewModel.kt
/*
* 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/>
*/
package net.taler.merchantpos.config
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import net.taler.common.Challenge
import net.taler.common.ChallengesResponse
import net.taler.common.TokenDuration
import net.taler.lib.android.ChallengeCancelledException
import net.taler.lib.android.formatMfaCode
import net.taler.lib.android.normalizeMfaCode
import net.taler.merchantpos.R
import io.ktor.client.plugins.ClientRequestException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.taler.common.R as CommonR
/** No Activity or Fragment references: a login attempt can outlive its screen. */
internal interface LoginBackend
{
suspend fun fetchToken(challengeIds: List<String>): String
suspend fun requestChallenge(id: String)
suspend fun confirmChallenge(
id: String,
code: String,
)
fun configure(token: String)
}
internal class MerchantLoginBackend(
private val manager: ConfigManager,
private val baseUrl: String,
private val username: String,
private val secret: String,
private val savePassword: Boolean,
) : LoginBackend
{
override suspend fun fetchToken(challengeIds: List<String>) =
withContext(Dispatchers.IO) {
manager.fetchLimitedAccessToken(baseUrl, username, secret, TokenDuration.Forever, challengeIds)
}
override suspend fun requestChallenge(id: String) =
withContext(Dispatchers.IO) {
manager.requestChallenge(baseUrl, username, id)
}
override suspend fun confirmChallenge(
id: String,
code: String,
) = withContext(Dispatchers.IO) {
manager.confirmChallenge(baseUrl, username, id, code)
}
override fun configure(token: String)
{
manager.fetchConfig(Config.New("$baseUrl/instances/$username", token, savePassword), true)
}
}
internal sealed class LoginPrompt
{
abstract fun cancel()
class Method(
val challenges: List<Challenge>,
) : LoginPrompt()
{
internal val answer = CompletableDeferred<Challenge?>()
fun select(challenge: Challenge)
{
if (challenge in challenges) answer.complete(challenge)
}
override fun cancel()
{
answer.complete(null)
}
}
class Code(
val challenge: Challenge,
initialError: Int? = null,
) : LoginPrompt()
{
internal val answer = CompletableDeferred<String?>()
var code by mutableStateOf("")
private set
var error by mutableStateOf(initialError)
private set
fun updateCode(value: String)
{
code = value.filter(Char::isDigit).take(8)
error = null
}
fun submit()
{
val normalized = normalizeMfaCode(code)
if (normalized == null)
{
error = CommonR.string.mfa_challenge_code_incomplete
}
else
{
answer.complete(formatMfaCode(normalized))
}
}
override fun cancel()
{
answer.complete(null)
}
}
}
/** Credentials and MFA state survive rotation in process memory only, never saved state. */
internal class ConfigDraftViewModel : ViewModel()
{
private var loginJob: Job? = null
private var attempt: Any? = null
var initialized = false
var mode: ConfigMode? = null
var merchantUrl: String? = null
var username: String? = null
var token: String? = null
var saveToken: Boolean? = null
var awaitingConfigUpdate = false
private set
var isSubmitting by mutableStateOf(false)
private set
var prompt by mutableStateOf<LoginPrompt?>(null)
private set
val loginError = MutableLiveData<Int?>()
fun startLogin(backend: LoginBackend)
{
if (isSubmitting) return
val currentAttempt = Any().also { attempt = it }
isSubmitting = true
loginError.value = null
loginJob =
viewModelScope.launch {
try
{
var solvedIds = emptyList<String>()
while (true)
{
val limitedToken =
try
{
backend.fetchToken(solvedIds)
}
catch (e: ChallengeRequiredException)
{
solvedIds = solveChallenges(backend, e.challengeResponse)
continue
}
awaitingConfigUpdate = true
backend.configure(limitedToken)
break
}
}
catch (_: ChallengeCancelledException)
{
// The user cancelled MFA. Keep the form available for another attempt.
}
catch (e: CancellationException)
{
throw e
}
catch (_: LocalNetworkAccessDenied)
{
awaitingConfigUpdate = false
loginError.value = R.string.config_local_network_required
}
catch (e: Exception)
{
awaitingConfigUpdate = false
loginError.value =
if (e is ClientRequestException &&
e.response.status.value == 401)
{
R.string.config_auth_error
}
else
{
R.string.config_error_network
}
}
finally
{
if (attempt === currentAttempt)
{
prompt = null
isSubmitting = awaitingConfigUpdate
loginJob = null
}
}
}
}
private suspend fun solveChallenges(
backend: LoginBackend,
response: ChallengesResponse,
): List<String>
{
if (response.challenges.isEmpty()) throw ChallengeCancelledException()
val challenges =
if (response.combiAnd)
{
response.challenges
}
else
{
val selection = LoginPrompt.Method(response.challenges)
prompt = selection
val selected =
try
{
selection.answer.await()
}
finally
{
if (prompt === selection) prompt = null
}
listOf(selected ?: throw ChallengeCancelledException())
}
val solved = mutableListOf<String>()
for (challenge in challenges)
{
backend.requestChallenge(challenge.challengeId)
var error: Int? = null
while (true)
{
val entry = LoginPrompt.Code(challenge, error)
prompt = entry
val code =
try
{
entry.answer.await()
}
finally
{
if (prompt === entry) prompt = null
}
?: throw ChallengeCancelledException()
try
{
backend.confirmChallenge(challenge.challengeId, code)
solved.add(challenge.challengeId)
break
}
catch (e: CancellationException)
{
throw e
}
catch (e: ClientRequestException)
{
when (e.response.status.value)
{
409 ->
{
error = CommonR.string.mfa_challenge_invalid
}
429 ->
{
backend.requestChallenge(challenge.challengeId)
error = CommonR.string.mfa_challenge_retry
}
else ->
{
throw e
}
}
}
}
}
return solved
}
fun finishConfigUpdate()
{
awaitingConfigUpdate = false
isSubmitting = false
}
fun cancelLogin()
{
attempt = null
loginJob?.cancel()
loginJob = null
prompt = null
isSubmitting = false
loginError.value = null
awaitingConfigUpdate = false
}
fun clear()
{
cancelLogin()
initialized = false
mode = null
merchantUrl = null
username = null
token = null
saveToken = null
awaitingConfigUpdate = false
}
}