diff options
13 files changed, 594 insertions, 99 deletions
diff --git a/anastasis/src/main/java/net/taler/anastasis/MainActivity.kt b/anastasis/src/main/java/net/taler/anastasis/MainActivity.kt index 3a4ddb8..bb0fd4d 100644 --- a/anastasis/src/main/java/net/taler/anastasis/MainActivity.kt +++ b/anastasis/src/main/java/net/taler/anastasis/MainActivity.kt @@ -25,8 +25,10 @@ import net.taler.anastasis.ui.common.SelectCountryScreen import net.taler.anastasis.ui.common.SelectUserAttributesScreen import net.taler.anastasis.ui.dialogs.ErrorDialog import net.taler.anastasis.ui.home.HomeScreen +import net.taler.anastasis.ui.recovery.RecoveryFinishedScreen import net.taler.anastasis.ui.recovery.SelectChallengeScreen import net.taler.anastasis.ui.recovery.SelectSecretScreen +import net.taler.anastasis.ui.recovery.SolveChallengeScreen import net.taler.anastasis.ui.theme.AnastasisTheme import net.taler.anastasis.viewmodels.ReducerViewModel @@ -113,6 +115,14 @@ fun MainNavHost( SelectChallengeScreen() } + Routes.SolveChallenge.route -> { + SolveChallengeScreen() + } + + Routes.RecoveryFinished.route -> { + RecoveryFinishedScreen() + } + Routes.RestoreInit.route -> { Text("This is the restore session screen!") } diff --git a/anastasis/src/main/java/net/taler/anastasis/Routes.kt b/anastasis/src/main/java/net/taler/anastasis/Routes.kt index 8b2d821..ac6e1d9 100644 --- a/anastasis/src/main/java/net/taler/anastasis/Routes.kt +++ b/anastasis/src/main/java/net/taler/anastasis/Routes.kt @@ -20,6 +20,8 @@ sealed class Routes( // Recovery object SelectSecret: Routes("select_secret") object SelectChallenge: Routes("select_challenge") + object SolveChallenge: Routes("solve_challenge") + object RecoveryFinished: Routes("recovery_finished") // Restore object RestoreInit: Routes("restore") diff --git a/anastasis/src/main/java/net/taler/anastasis/models/ChallengeFeedback.kt b/anastasis/src/main/java/net/taler/anastasis/models/ChallengeFeedback.kt index 57496db..1368d9f 100644 --- a/anastasis/src/main/java/net/taler/anastasis/models/ChallengeFeedback.kt +++ b/anastasis/src/main/java/net/taler/anastasis/models/ChallengeFeedback.kt @@ -9,76 +9,76 @@ import net.taler.common.Amount @OptIn(ExperimentalSerializationApi::class) @Serializable @JsonClassDiscriminator("state") -abstract class ChallengeFeedback +sealed class ChallengeFeedback { + @Serializable + @SerialName("solved") + object Solved: ChallengeFeedback() -@Serializable -@SerialName("solved") -class ChallengeFeedbackSolved: ChallengeFeedback() + @Serializable + @SerialName("incorrect-answer") + object IncorrectAnswer: ChallengeFeedback() -@Serializable -@SerialName("incorrect-answer") -class ChallengeFeedbackIncorrectAnswer: ChallengeFeedback() + @Serializable + @SerialName("code-in-file") + data class CodeInFile( + val filename: String, + @SerialName("display_hint") + val displayHint: String, + ): ChallengeFeedback() -@Serializable -@SerialName("code-in-file") -data class ChallengeFeedbackCodeInFile( - val filename: String, - @SerialName("display_hint") - val displayHint: String, -): ChallengeFeedback() + @Serializable + @SerialName("code-sent") + data class CodeSent( + @SerialName("display_hint") + val displayHint: String, + @SerialName("address_hint") + val addressHint: String, + ): ChallengeFeedback() -@Serializable -@SerialName("code-sent") -data class ChallengeFeedbackCodeSent( - @SerialName("display_hint") - val displayHint: String, - @SerialName("address_hint") - val addressHint: String, -): ChallengeFeedback() + @Serializable + @SerialName("unsupported") + data class Unsupported( + @SerialName("unsupported_method") + val unsupportedMethod: String, + ): ChallengeFeedback() -@Serializable -@SerialName("unsupported") -data class ChallengeFeedbackUnsupported( - @SerialName("unsupported_method") - val unsupportedMethod: String, -): ChallengeFeedback() + @Serializable + @SerialName("rate-limit-exceeded") + object RateLimitExceeded : ChallengeFeedback() -@Serializable -@SerialName("rate-limit-exceeded") -class ChallengeFeedbackRateLimitExceeded: ChallengeFeedback() + @Serializable + @SerialName("iban-instructions") + data class BankTransferRequired( + @SerialName("challenge_amount") + val challengeAmount: Amount, + @SerialName("target_iban") + val targetIban: String, + @SerialName("target_business_name") + val targetBusinessName: String, + @SerialName("wire_transfer_subject") + val wireTransferSubject: String, + @SerialName("answer_code") + val answerCode: Int, + ): ChallengeFeedback() -@Serializable -@SerialName("iban-instructions") -data class ChallengeFeedbackBankTransferRequired( - @SerialName("challenge_amount") - val challengeAmount: Amount, - @SerialName("target_iban") - val targetIban: String, - @SerialName("target_business_name") - val targetBusinessName: String, - @SerialName("wire_transfer_subject") - val wireTransferSubject: String, - @SerialName("answer_code") - val answerCode: Int, -): ChallengeFeedback() + @Serializable + @SerialName("server-failure") + class ServerFailure( + val httpStatus: Int, + // TODO: error_response + ): ChallengeFeedback() -@Serializable -@SerialName("server-failure") -class ChallengeFeedbackServerFailure( - // TODO: http_status - // TODO: error_response -): ChallengeFeedback() + @Serializable + @SerialName("truth-unknown") + object TruthUnknown: ChallengeFeedback() -@Serializable -@SerialName("truth-unknown") -class ChallengeFeedbackTruthUnknown: ChallengeFeedback() - -@Serializable -@SerialName("taler-payment") -class ChallengeFeedbackTalerPaymentRequired( - @SerialName("taler_pay_uri") - val talerPayUri: String, - val provider: String, - @SerialName("payment_secret") - val paymentSecret: String, -): ChallengeFeedback()
\ No newline at end of file + @Serializable + @SerialName("taler-payment") + class TalerPaymentRequired( + @SerialName("taler_pay_uri") + val talerPayUri: String, + val provider: String, + @SerialName("payment_secret") + val paymentSecret: String, + ): ChallengeFeedback() +}
\ No newline at end of file diff --git a/anastasis/src/main/java/net/taler/anastasis/models/ReducerState.kt b/anastasis/src/main/java/net/taler/anastasis/models/ReducerState.kt index e12a176..72c1f48 100644 --- a/anastasis/src/main/java/net/taler/anastasis/models/ReducerState.kt +++ b/anastasis/src/main/java/net/taler/anastasis/models/ReducerState.kt @@ -107,7 +107,9 @@ sealed class ReducerState { val selectedVersion: SelectedVersionInfo? = null, @SerialName("challenge_feedback") val challengeFeedback: Map<String, ChallengeFeedback>? = null, - // TODO: recovered_key_shares + @SerialName("recovered_key_shares") + val recoveredKeyShares: Map<String, String>? = null, + @SerialName("core_secret") val coreSecret: CoreSecret? = null, @SerialName("authentication_providers") val authenticationProviders: Map<String, AuthenticationProviderStatus>? = null, diff --git a/anastasis/src/main/java/net/taler/anastasis/reducers/ReducerManager.kt b/anastasis/src/main/java/net/taler/anastasis/reducers/ReducerManager.kt index 38b63e0..db9f5ff 100644 --- a/anastasis/src/main/java/net/taler/anastasis/reducers/ReducerManager.kt +++ b/anastasis/src/main/java/net/taler/anastasis/reducers/ReducerManager.kt @@ -295,4 +295,15 @@ class ReducerManager( .onError { this@ReducerManager.onError(it) } } } + + fun solveChallenge(answer: String) = scope.launch { + state.value?.let { initialState -> + addTask() + api.reduceAction(initialState, "solve_challenge") { + put("answer", answer) + } + .onSuccess { this@ReducerManager.onSuccess(it) } + .onError { this@ReducerManager.onError(it) } + } + } }
\ No newline at end of file diff --git a/anastasis/src/main/java/net/taler/anastasis/shared/Utils.kt b/anastasis/src/main/java/net/taler/anastasis/shared/Utils.kt index 916dd36..ee3799d 100644 --- a/anastasis/src/main/java/net/taler/anastasis/shared/Utils.kt +++ b/anastasis/src/main/java/net/taler/anastasis/shared/Utils.kt @@ -17,9 +17,6 @@ package net.taler.anastasis.shared import android.os.Build -import io.matthewnelson.encoding.base32.Base32 -import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray -import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow import kotlinx.datetime.Clock @@ -31,7 +28,6 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.json.JSONArray import org.json.JSONObject -import java.nio.charset.Charset import java.text.SimpleDateFormat import java.time.format.DateTimeFormatter import java.util.Locale @@ -58,17 +54,6 @@ object Utils { inline fun <reified T> Json.encodeToNativeJson(value: Collection<T>): JSONArray = JSONArray(encodeToString(value)) - fun encodeBase32 (input: String) = input - .toByteArray(Charset.defaultCharset()) - .encodeToString(Base32.Crockford) - - fun encodeBase32 (input: ByteArray) = input - .encodeToString(Base32.Crockford) - - fun decodeBase32 (input: String) = input - .decodeToByteArray(Base32.Crockford) - .toString(Charset.defaultCharset()) - // Source: https://stackoverflow.com/questions/54827455/how-to-implement-timer-with-kotlin-coroutines/54828055#54828055 fun tickerFlow(period: Duration, initialDelay: Duration = Duration.ZERO) = flow { delay(initialDelay) @@ -77,4 +62,6 @@ object Utils { delay(period) } } + + fun extractVerificationCode(code: String) = code.split('-').drop(1).joinToString("") }
\ No newline at end of file diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/forms/EditAnswerForm.kt b/anastasis/src/main/java/net/taler/anastasis/ui/forms/EditAnswerForm.kt new file mode 100644 index 0000000..aacc8a6 --- /dev/null +++ b/anastasis/src/main/java/net/taler/anastasis/ui/forms/EditAnswerForm.kt @@ -0,0 +1,121 @@ +/* + * This file is part of GNU Taler + * (C) 2023 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.anastasis.ui.forms + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import net.taler.anastasis.R +import net.taler.anastasis.shared.FieldStatus +import net.taler.anastasis.ui.theme.AnastasisTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditAnswerForm( + questionLabel: String? = null, + question: String? = null, + answerLabel: String, + answer: String = "", + onAnswerEdited: (answer: String) -> Unit, + regex: String? = null, +) { + val focusRequester1 = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + val status = remember(answer) { + fieldStatus(answer, regex) + } + + Column { + if (question != null) { + OutlinedTextField( + modifier = Modifier + .focusRequester(focusRequester1) + .fillMaxWidth(), + value = question, + maxLines = 1, + enabled = false, + label = { Text(questionLabel ?: stringResource(R.string.question)) }, + onValueChange = {}, + ) + } + + OutlinedTextField( + modifier = Modifier + .focusRequester(focusRequester1) + .fillMaxWidth(), + value = answer, + isError = status.error, + supportingText = { + status.msgRes?.let { Text(stringResource(it)) } + }, + maxLines = 1, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChange = onAnswerEdited, + label = { Text(answerLabel) }, + ) + } + + LaunchedEffect(Unit) { + focusRequester1.requestFocus() + } +} + +private fun fieldStatus(answer: String, regex: String? = null): FieldStatus = if (answer.isBlank()) { + FieldStatus.Blank +} else if (regex?.toRegex()?.matches(answer) != false) { + FieldStatus.Valid +} else { + FieldStatus.Invalid +} + +@Preview +@Composable +fun EditCodeFormPreview() { + var code by remember { mutableStateOf("A-65611-546-7467-369") } + AnastasisTheme { + Surface { + EditAnswerForm( + answer = code, + answerLabel = stringResource(R.string.code), + onAnswerEdited = { code = it }, + ) + } + } +}
\ No newline at end of file diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/forms/EditQuestionForm.kt b/anastasis/src/main/java/net/taler/anastasis/ui/forms/EditQuestionForm.kt index fb17088..cac228c 100644 --- a/anastasis/src/main/java/net/taler/anastasis/ui/forms/EditQuestionForm.kt +++ b/anastasis/src/main/java/net/taler/anastasis/ui/forms/EditQuestionForm.kt @@ -39,6 +39,7 @@ import net.taler.anastasis.models.AuthMethod @Composable fun EditQuestionForm( method: AuthMethod? = null, + isAnswer: Boolean = false, onMethodEdited: (method: AuthMethod) -> Unit, ) { val localMethod = method ?: AuthMethod( @@ -59,6 +60,7 @@ fun EditQuestionForm( .fillMaxWidth(), value = localMethod.instructions, maxLines = 1, + enabled = !isAnswer, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), keyboardActions = KeyboardActions(onNext = { focusRequester2.requestFocus() }), onValueChange = { diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/recovery/RecoveryFinishedScreen.kt b/anastasis/src/main/java/net/taler/anastasis/ui/recovery/RecoveryFinishedScreen.kt new file mode 100644 index 0000000..9cc58e5 --- /dev/null +++ b/anastasis/src/main/java/net/taler/anastasis/ui/recovery/RecoveryFinishedScreen.kt @@ -0,0 +1,142 @@ +/* + * This file is part of GNU Taler + * (C) 2023 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.anastasis.ui.recovery + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloudDone +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import net.taler.anastasis.R +import net.taler.anastasis.models.CoreSecret +import net.taler.anastasis.models.RecoveryInternalData +import net.taler.anastasis.models.RecoveryStates +import net.taler.anastasis.models.ReducerState +import net.taler.anastasis.ui.reusable.pages.WizardPage +import net.taler.anastasis.ui.theme.LocalSpacing +import net.taler.anastasis.viewmodels.FakeReducerViewModel +import net.taler.anastasis.viewmodels.ReducerViewModel +import net.taler.anastasis.viewmodels.ReducerViewModelI +import net.taler.common.CryptoUtils + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RecoveryFinishedScreen( + viewModel: ReducerViewModelI = hiltViewModel<ReducerViewModel>(), +) { + val state by viewModel.reducerState.collectAsState() + val reducerState = state as? ReducerState.Recovery + ?: error("invalid reducer state type") + + val recoveryDocument = reducerState.recoveryDocument!! + val coreSecret = reducerState.coreSecret!! + + WizardPage( + title = stringResource(R.string.recovery_finished_title), + onBackClicked = { viewModel.goHome() }, + showNext = false, + showPrev = false, + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(it), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { + Box( + modifier = Modifier + .padding(LocalSpacing.current.large) + .background(MaterialTheme.colorScheme.primary, shape = CircleShape) + .fillMaxWidth(0.4f) + .aspectRatio(1f), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.CloudDone, + modifier = Modifier.fillMaxSize(0.5f), + tint = MaterialTheme.colorScheme.onPrimary, + contentDescription = stringResource(R.string.success), + ) + } + } + + item { + Text( + recoveryDocument.secretName, + modifier = Modifier.padding(LocalSpacing.current.medium), + style = MaterialTheme.typography.titleLarge, + ) + } + + item { + OutlinedTextField( + modifier = Modifier + .padding( + start = LocalSpacing.current.medium, + end = LocalSpacing.current.medium, + bottom = LocalSpacing.current.small, + ).fillMaxWidth(), + value = CryptoUtils.decodeCrock(coreSecret.value).toString(Charsets.UTF_8), + readOnly = true, + onValueChange = {}, + label = { Text(stringResource(R.string.secret_text)) }, + ) + } + } + } +} + +@Preview +@Composable +fun RecoveryFinishedScreenPreview() { + RecoveryFinishedScreen( + viewModel = FakeReducerViewModel( + state = ReducerState.Recovery( + recoveryState = RecoveryStates.RecoveryFinished, + recoveryDocument = RecoveryInternalData( + secretName = "Secret", + providerUrl = "http://localhost:8089", + version = 1, + ), + coreSecret = CoreSecret( + mime = "text/plain", + value = CryptoUtils.encodeCrock("Taler".toByteArray(Charsets.UTF_8)), + ) + ), + ), + ) +}
\ No newline at end of file diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/recovery/SelectChallengeScreen.kt b/anastasis/src/main/java/net/taler/anastasis/ui/recovery/SelectChallengeScreen.kt index 9060e22..df7afdf 100644 --- a/anastasis/src/main/java/net/taler/anastasis/ui/recovery/SelectChallengeScreen.kt +++ b/anastasis/src/main/java/net/taler/anastasis/ui/recovery/SelectChallengeScreen.kt @@ -16,18 +16,24 @@ package net.taler.anastasis.ui.recovery +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -40,6 +46,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import net.taler.anastasis.R import net.taler.anastasis.models.AuthMethod +import net.taler.anastasis.models.ChallengeFeedback import net.taler.anastasis.models.ChallengeInfo import net.taler.anastasis.models.RecoveryInformation import net.taler.anastasis.models.RecoveryStates @@ -60,6 +67,7 @@ fun SelectChallengeScreen( val policies = reducerState.recoveryInformation?.policies ?: emptyList() val challenges = reducerState.recoveryInformation?.challenges ?: emptyList() + val challengeFeedback = reducerState.challengeFeedback ?: emptyMap() WizardPage( title = stringResource(R.string.select_challenge_title), @@ -83,6 +91,7 @@ fun SelectChallengeScreen( policy = policies[index], policyIndex = index, challenges = challenges, + challengeFeedback = challengeFeedback, onChallengeClick = { uuid -> viewModel.reducerManager?.selectChallenge(uuid) } @@ -98,6 +107,7 @@ fun PolicyCard( policy: List<RecoveryInformation.Policy>, policyIndex: Int, challenges: List<ChallengeInfo>, + challengeFeedback: Map<String, ChallengeFeedback>, onChallengeClick: (uuid: String) -> Unit, ) { ElevatedCard( @@ -119,6 +129,7 @@ fun PolicyCard( .padding(top = LocalSpacing.current.small) .fillMaxWidth(), challenge = challenge, + challengeFeedback = challengeFeedback[challenge.uuid], onClick = { onChallengeClick(uuid) }, ) } @@ -131,34 +142,85 @@ fun PolicyCard( fun ChallengeCard( modifier: Modifier = Modifier, challenge: ChallengeInfo, + challengeFeedback: ChallengeFeedback? = null, onClick: () -> Unit, ) { OutlinedCard( modifier = modifier, ) { - Row( - modifier = Modifier.padding(LocalSpacing.current.medium), - verticalAlignment = Alignment.CenterVertically, + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.End, ) { - Icon( - challenge.type.icon, - tint = MaterialTheme.colorScheme.onBackground, - contentDescription = stringResource(challenge.type.nameRes), - ) - Spacer(Modifier.width(LocalSpacing.current.medium)) - Text( - challenge.instructions, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.labelLarge, - ) - Spacer(Modifier.width(LocalSpacing.current.medium)) - Button(onClick = onClick) { - Text(stringResource(R.string.challenge_solve)) + Row( + modifier = Modifier.padding(LocalSpacing.current.medium), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + challenge.type.icon, + tint = MaterialTheme.colorScheme.onBackground, + contentDescription = stringResource(challenge.type.nameRes), + ) + Spacer(Modifier.width(LocalSpacing.current.medium)) + Text( + challenge.instructions, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelLarge, + ) + Spacer(Modifier.width(LocalSpacing.current.medium)) + when (challengeFeedback) { + is ChallengeFeedback.Solved -> FeedbackSolvedButton() + else -> Button(onClick = onClick) { + Text(stringResource(R.string.challenge_solve)) + } + } + } + + if (challengeFeedback != null && challengeFeedback !is ChallengeFeedback.Solved) { + Box(Modifier.padding( + end = LocalSpacing.current.medium, + bottom = LocalSpacing.current.medium, + start = LocalSpacing.current.medium, + )) { + Text( + when (challengeFeedback) { + is ChallengeFeedback.Solved -> return@Box + is ChallengeFeedback.IncorrectAnswer -> stringResource(R.string.challenge_feedback_incorrect_answer) + is ChallengeFeedback.CodeInFile -> challengeFeedback.displayHint + is ChallengeFeedback.CodeSent -> challengeFeedback.displayHint + is ChallengeFeedback.Unsupported -> stringResource(R.string.challenge_feedback_unsupported) + is ChallengeFeedback.RateLimitExceeded -> stringResource(R.string.challenge_feedback_rate_limit_exceeded) + is ChallengeFeedback.BankTransferRequired -> stringResource(R.string.challenge_feedback_bank_transfer_required) + is ChallengeFeedback.ServerFailure -> stringResource(R.string.challenge_feedback_server_failure) + is ChallengeFeedback.TruthUnknown -> stringResource(R.string.challenge_feedback_truth_unknown) + is ChallengeFeedback.TalerPaymentRequired -> stringResource(R.string.challenge_feedback_taler_payment_required) + }, + style = MaterialTheme.typography.labelMedium + .copy(color = MaterialTheme.colorScheme.error), + ) + } } } } } +@Composable +fun FeedbackSolvedButton() { + OutlinedButton( + onClick = {}, + enabled = false, + ) { + val label = stringResource(R.string.challenge_feedback_solved) + Icon( + imageVector = Icons.Default.Check, + contentDescription = label, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(label) + } +} + @Preview @Composable fun SelectChallengeScreenPreview() { @@ -186,6 +248,10 @@ fun SelectChallengeScreenPreview() { ), ), ), + challengeFeedback = mapOf( + "RNB84NQZPCM3MZWF9D5FFMSYYN07J2NAT5N8Q0DBHHT7R3GJ4AA0" to ChallengeFeedback.IncorrectAnswer, + "ZA6T35B8XAR0DNKS5H100GK8PDPTA7Q8ST2FPQSYAZ4QRAA9XKK0" to ChallengeFeedback.Solved, + ), ), ), ) diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/recovery/SolveChallengeScreen.kt b/anastasis/src/main/java/net/taler/anastasis/ui/recovery/SolveChallengeScreen.kt new file mode 100644 index 0000000..ece93d5 --- /dev/null +++ b/anastasis/src/main/java/net/taler/anastasis/ui/recovery/SolveChallengeScreen.kt @@ -0,0 +1,141 @@ +/* + * This file is part of GNU Taler + * (C) 2023 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.anastasis.ui.recovery + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import net.taler.anastasis.R +import net.taler.anastasis.models.AuthMethod +import net.taler.anastasis.models.ChallengeFeedback +import net.taler.anastasis.models.ReducerState +import net.taler.anastasis.shared.Utils +import net.taler.anastasis.ui.forms.EditAnswerForm +import net.taler.anastasis.ui.reusable.pages.WizardPage +import net.taler.anastasis.ui.theme.LocalSpacing +import net.taler.anastasis.viewmodels.ReducerViewModel +import net.taler.anastasis.viewmodels.ReducerViewModelI + +@Composable +fun SolveChallengeScreen( + viewModel: ReducerViewModelI = hiltViewModel<ReducerViewModel>(), +) { + val state by viewModel.reducerState.collectAsState() + val reducerState = state as? ReducerState.Recovery + ?: error("invalid reducer state type") + + val selectedChallengeUuid = reducerState.selectedChallengeUuid + val challenge = remember(selectedChallengeUuid) { + reducerState.recoveryInformation?.challenges?.find { + it.uuid == selectedChallengeUuid + } ?: error("empty challenge") + } + val challengeFeedback = remember(selectedChallengeUuid) { + reducerState.challengeFeedback?.get(challenge.uuid) + } + + val question by remember { mutableStateOf(challenge.instructions) } + var answer by remember { mutableStateOf("") } + + WizardPage( + title = stringResource(R.string.solve_challenge_title), + onBackClicked = { viewModel.goHome() }, + onPrevClicked = { viewModel.goBack() }, + onNextClicked = { + when (challenge.type) { + AuthMethod.Type.Question -> { + viewModel.reducerManager?.solveChallenge(answer) + } + AuthMethod.Type.Sms, AuthMethod.Type.Email -> { + viewModel.reducerManager?.solveChallenge(Utils.extractVerificationCode(answer)) + } + // TODO: handle other challenge types + else -> {} + } + }, + ) { + Column { + if (challengeFeedback != null && challengeFeedback !is ChallengeFeedback.Solved) { + Box( + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Text( + when (challengeFeedback) { + is ChallengeFeedback.Solved -> return@Box + is ChallengeFeedback.IncorrectAnswer -> stringResource(R.string.challenge_feedback_incorrect_answer) + is ChallengeFeedback.CodeInFile -> challengeFeedback.displayHint + is ChallengeFeedback.CodeSent -> challengeFeedback.displayHint + is ChallengeFeedback.Unsupported -> stringResource(R.string.challenge_feedback_unsupported) + is ChallengeFeedback.RateLimitExceeded -> stringResource(R.string.challenge_feedback_rate_limit_exceeded) + is ChallengeFeedback.BankTransferRequired -> stringResource(R.string.challenge_feedback_bank_transfer_required) + is ChallengeFeedback.ServerFailure -> stringResource(R.string.challenge_feedback_server_failure) + is ChallengeFeedback.TruthUnknown -> stringResource(R.string.challenge_feedback_truth_unknown) + is ChallengeFeedback.TalerPaymentRequired -> stringResource(R.string.challenge_feedback_taler_payment_required) + }, + modifier = Modifier.padding(LocalSpacing.current.small), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onPrimary, + ) + } + } + Box(Modifier.padding(LocalSpacing.current.medium)) { + when (challenge.type) { + AuthMethod.Type.Question -> EditAnswerForm( + question = question, + answerLabel = stringResource(R.string.answer), + answer = answer, + onAnswerEdited = { answer = it } + ) + + AuthMethod.Type.Sms, AuthMethod.Type.Email -> EditAnswerForm( + answerLabel = stringResource(R.string.code), + answer = answer, + onAnswerEdited = { answer = it }, + regex = "^A-\\d{5}-\\d{3}-\\d{4}-\\d{3}$", + ) + + // TODO: handle other challenge types + else -> {} + } + } + } + } +} + +@Preview +@Composable +fun SolveChallengeScreenPreview() { + SolveChallengeScreen() +}
\ No newline at end of file diff --git a/anastasis/src/main/java/net/taler/anastasis/viewmodels/ReducerViewModel.kt b/anastasis/src/main/java/net/taler/anastasis/viewmodels/ReducerViewModel.kt index 24c17d6..faba94a 100644 --- a/anastasis/src/main/java/net/taler/anastasis/viewmodels/ReducerViewModel.kt +++ b/anastasis/src/main/java/net/taler/anastasis/viewmodels/ReducerViewModel.kt @@ -92,8 +92,8 @@ class ReducerViewModel @Inject constructor(): ViewModel(), ReducerViewModelI { Routes.SelectChallenge.route } RecoveryStates.ChallengePaying -> TODO() - RecoveryStates.ChallengeSolving -> TODO() - RecoveryStates.RecoveryFinished -> TODO() + RecoveryStates.ChallengeSolving -> Routes.SolveChallenge.route + RecoveryStates.RecoveryFinished -> Routes.RecoveryFinished.route } else -> Routes.Home.route } diff --git a/anastasis/src/main/res/values/strings.xml b/anastasis/src/main/res/values/strings.xml index 9aeebb5..b7dfed7 100644 --- a/anastasis/src/main/res/values/strings.xml +++ b/anastasis/src/main/res/values/strings.xml @@ -32,6 +32,7 @@ <string name="field_empty">This field is required</string> <string name="field_invalid">This field is invalid</string> <string name="field_optional">This field is optional</string> + <string name="code">Verification code</string> <!-- Common --> <string name="select_continent_title">Where do you live?</string> @@ -70,5 +71,15 @@ <!-- Recovery --> <string name="select_secret_title">Select secret</string> <string name="select_challenge_title">Select challenge</string> + <string name="solve_challenge_title">Solve challenge</string> + <string name="recovery_finished_title">Your secret was recovered</string> <string name="challenge_solve">Solve</string> + <string name="challenge_feedback_solved">Solved</string> + <string name="challenge_feedback_incorrect_answer">Incorrect answer</string> + <string name="challenge_feedback_unsupported">Method unsupported</string> + <string name="challenge_feedback_rate_limit_exceeded">Rate limit exceeded</string> + <string name="challenge_feedback_bank_transfer_required">Bank transfer required</string> + <string name="challenge_feedback_server_failure">There was a server failure</string> + <string name="challenge_feedback_taler_payment_required">Taler payment required</string> + <string name="challenge_feedback_truth_unknown">Can\'t solve challenge</string> </resources>
\ No newline at end of file |