summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIván Ávalos <avalos@disroot.org>2023-08-24 12:53:22 -0600
committerIván Ávalos <avalos@disroot.org>2023-11-11 13:20:09 -0600
commit91fe994f279c16789eaf8955dcdc7bda0a1092cc (patch)
treede4ef2b58cb35c2252ab98de52a12b172051ab82
parentd4daaa4adf7850c9dc5a00ae4e9681517f5e17fe (diff)
downloadtaler-android-91fe994f279c16789eaf8955dcdc7bda0a1092cc.tar.gz
taler-android-91fe994f279c16789eaf8955dcdc7bda0a1092cc.tar.bz2
taler-android-91fe994f279c16789eaf8955dcdc7bda0a1092cc.zip
Basic recovery flow working
Signed-off-by: Iván Ávalos <avalos@disroot.org>
-rw-r--r--anastasis/src/main/java/net/taler/anastasis/MainActivity.kt10
-rw-r--r--anastasis/src/main/java/net/taler/anastasis/Routes.kt2
-rw-r--r--anastasis/src/main/java/net/taler/anastasis/models/ChallengeFeedback.kt128
-rw-r--r--anastasis/src/main/java/net/taler/anastasis/models/ReducerState.kt4
-rw-r--r--anastasis/src/main/java/net/taler/anastasis/reducers/ReducerManager.kt11
-rw-r--r--anastasis/src/main/java/net/taler/anastasis/shared/Utils.kt17
-rw-r--r--anastasis/src/main/java/net/taler/anastasis/ui/forms/EditAnswerForm.kt121
-rw-r--r--anastasis/src/main/java/net/taler/anastasis/ui/forms/EditQuestionForm.kt2
-rw-r--r--anastasis/src/main/java/net/taler/anastasis/ui/recovery/RecoveryFinishedScreen.kt142
-rw-r--r--anastasis/src/main/java/net/taler/anastasis/ui/recovery/SelectChallengeScreen.kt100
-rw-r--r--anastasis/src/main/java/net/taler/anastasis/ui/recovery/SolveChallengeScreen.kt141
-rw-r--r--anastasis/src/main/java/net/taler/anastasis/viewmodels/ReducerViewModel.kt4
-rw-r--r--anastasis/src/main/res/values/strings.xml11
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