diff options
author | Iván Ávalos <avalos@disroot.org> | 2023-07-28 23:20:08 -0600 |
---|---|---|
committer | Iván Ávalos <avalos@disroot.org> | 2023-11-11 13:20:09 -0600 |
commit | a31ca9fa74e07bc06af156c83592471ac3964121 (patch) | |
tree | a35381294ade8ebe081eeec958e2c156f9939b14 | |
parent | 22da595621eb544c35629324a99973df1ac3b3de (diff) | |
download | taler-android-a31ca9fa74e07bc06af156c83592471ac3964121.tar.gz taler-android-a31ca9fa74e07bc06af156c83592471ac3964121.tar.bz2 taler-android-a31ca9fa74e07bc06af156c83592471ac3964121.zip |
Initial challenges screen + multiple fixes
Signed-off-by: Iván Ávalos <avalos@disroot.org>
20 files changed, 734 insertions, 92 deletions
diff --git a/anastasis/build.gradle.kts b/anastasis/build.gradle.kts index 7aceb34..eedf4f2 100644 --- a/anastasis/build.gradle.kts +++ b/anastasis/build.gradle.kts @@ -66,6 +66,7 @@ dependencies { implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material:material") implementation("androidx.compose.material3:material3") implementation("androidx.navigation:navigation-compose:2.6.0") implementation("androidx.compose.material:material-icons-extended:1.4.3") @@ -73,6 +74,7 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0") implementation("com.google.dagger:hilt-android:2.44") implementation("androidx.hilt:hilt-navigation-compose:1.0.0") + implementation("io.matthewnelson.encoding:base32:2.0.0") kapt("com.google.dagger:hilt-android-compiler:2.44") debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") diff --git a/anastasis/src/main/java/net/taler/anastasis/MainActivity.kt b/anastasis/src/main/java/net/taler/anastasis/MainActivity.kt index 4868357..18c83cd 100644 --- a/anastasis/src/main/java/net/taler/anastasis/MainActivity.kt +++ b/anastasis/src/main/java/net/taler/anastasis/MainActivity.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import dagger.hilt.android.AndroidEntryPoint +import net.taler.anastasis.ui.backup.SelectAuthMethodsScreen import net.taler.anastasis.ui.common.SelectContinentScreen import net.taler.anastasis.ui.common.SelectCountryScreen import net.taler.anastasis.ui.common.SelectUserAttributesScreen @@ -55,6 +56,9 @@ fun MainNavHost( Routes.SelectUserAttributes.route -> { SelectUserAttributesScreen() } + Routes.SelectAuthMethods.route -> { + SelectAuthMethodsScreen() + } 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 1a0accc..6d00cad 100644 --- a/anastasis/src/main/java/net/taler/anastasis/Routes.kt +++ b/anastasis/src/main/java/net/taler/anastasis/Routes.kt @@ -11,6 +11,9 @@ sealed class Routes( object SelectCountry: Routes("select_country") object SelectUserAttributes: Routes("select_user_attributes") + // Backup + object SelectAuthMethods: Routes("select_auth_methods") + // Restore object RestoreInit: Routes("restore") diff --git a/anastasis/src/main/java/net/taler/anastasis/Utils.kt b/anastasis/src/main/java/net/taler/anastasis/Utils.kt index 0ddd54d..8673a05 100644 --- a/anastasis/src/main/java/net/taler/anastasis/Utils.kt +++ b/anastasis/src/main/java/net/taler/anastasis/Utils.kt @@ -17,11 +17,21 @@ package net.taler.anastasis 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.LocalDate import kotlinx.datetime.toJavaLocalDate +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.json.JSONObject +import java.nio.charset.Charset import java.text.SimpleDateFormat import java.time.format.DateTimeFormatter import java.util.Locale +import kotlin.time.Duration object Utils { fun formatDate(date: LocalDate): String { @@ -34,4 +44,24 @@ object Utils { return formatter.format(date) } } + + inline fun <reified T> Json.encodeToNativeJson(value: T): JSONObject = + JSONObject(encodeToString(value)) + + fun encodeBase32 (input: String) = input + .toByteArray(Charset.defaultCharset()) + .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) + while (true) { + emit(Unit) + delay(period) + } + } }
\ No newline at end of file diff --git a/anastasis/src/main/java/net/taler/anastasis/backend/AnastasisReducerApi.kt b/anastasis/src/main/java/net/taler/anastasis/backend/AnastasisReducerApi.kt index 42ce566..c179d8f 100644 --- a/anastasis/src/main/java/net/taler/anastasis/backend/AnastasisReducerApi.kt +++ b/anastasis/src/main/java/net/taler/anastasis/backend/AnastasisReducerApi.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.decodeFromJsonElement +import net.taler.anastasis.Utils.encodeToNativeJson import net.taler.anastasis.models.ReducerState import net.taler.common.ApiResponse import net.taler.common.ApiResponse.* @@ -83,4 +84,29 @@ class AnastasisReducerApi() { WalletResponse.Error(info) } } + + suspend inline fun <reified T> reduceAction( + state: ReducerState, + action: String, + args: T? = null, + ): WalletResponse<ReducerState> = withContext(Dispatchers.Default) { + val json = BackendManager.json + val body = JSONObject().apply { + put("state", json.encodeToNativeJson(state)) + put("action", action) + if (args != null) put("args", json.encodeToNativeJson(args)) + } + try { + when (val response = sendRequest("anastasisReduce", body)) { + is Response -> { + val t = json.decodeFromJsonElement<ReducerState>(response.result) + WalletResponse.Success(t) + } + is Error -> error("invalid reducer response") + } + } catch (e: Exception) { + val info = TalerErrorInfo(NONE, "", e.toString()) + WalletResponse.Error(info) + } + } } diff --git a/anastasis/src/main/java/net/taler/anastasis/models/ReducerArgs.kt b/anastasis/src/main/java/net/taler/anastasis/models/ReducerArgs.kt new file mode 100644 index 0000000..c87e76d --- /dev/null +++ b/anastasis/src/main/java/net/taler/anastasis/models/ReducerArgs.kt @@ -0,0 +1,74 @@ +/* + * 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.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed class ReducerArgs { + + @Serializable + data class EnterUserAttributes( + @SerialName("identity_attributes") + val identityAttributes: Map<String, String>, + ) + + // TODO: ActionArgsAddProvider + + // TODO: ActionArgsDeleteProvider + + @Serializable + data class AddAuthentication( + @SerialName("authentication_method") + val authenticationMethod: AuthMethod, + ) + + // TODO: ActionArgsDeleteAuthentication + + // TODO: ActionArgsDeletePolicy + + // TODO: ActionArgsEnterSecretName + + // TODO: ActionArgsEnterSecret + + @Serializable + data class SelectContinent( + val continent: String, + ) + + @Serializable + data class SelectCountry( + @SerialName("country_code") + val countryCode: String, + ) + + // TODO: ActionArgsSelectChallenge + + // TODO: ActionArgsSolveChallengeRequest + + // TODO: ActionArgsAddPolicy + + // TODO: ActionArgsUpdateExpiration + + // TODO: ActionArgsUpdateExpiration + + // TODO: ActionArgsChangeVersion + + // TODO: ActionArgsUpdatePolicy + +}
\ 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 37f380f..83afc88 100644 --- a/anastasis/src/main/java/net/taler/anastasis/models/ReducerState.kt +++ b/anastasis/src/main/java/net/taler/anastasis/models/ReducerState.kt @@ -305,7 +305,7 @@ sealed class AuthenticationProviderStatus { val annualFee: String, @SerialName("business_name") val businessName: String, - val currency: String, + val currency: String? = null, @SerialName("http_status") val httpStatus: Int, @SerialName("liability_limit") @@ -335,30 +335,6 @@ sealed class AuthenticationProviderStatus { // TODO: ReducerStateBackupUserAttributesCollecting -// TODO: ActionArgsEnterUserAttributes - -// TODO: ActionArgsAddProvider - -// TODO: ActionArgsDeleteProvider - -// TODO: ActionArgsAddAuthentication - -// TODO: ActionArgsDeleteAuthentication - -// TODO: ActionArgsDeletePolicy - -// TODO: ActionArgsEnterSecretName - -// TODO: ActionArgsEnterSecret - -// TODO: ActionArgsSelectContinent - -// TODO: ActionArgsSelectCountry - -// TODO: ActionArgsSelectChallenge - -// TODO: ActionArgsSolveChallengeRequest - // TODO: SolveChallengeAnswerRequest // TODO: SolveChallengePinRequest @@ -367,12 +343,6 @@ sealed class AuthenticationProviderStatus { // TODO: PolicyMember -// TODO: ActionArgsAddPolicy - -// TODO: ActionArgsUpdateExpiration - -// TODO: ActionArgsUpdateExpiration - @Serializable data class SelectedVersionInfoProviders( val url: String, @@ -385,10 +355,6 @@ data class SelectedVersionInfo( val providers: SelectedVersionInfoProviders, ) -// TODO: ActionArgsChangeVersion - -// TODO: ActionArgsUpdatePolicy - // TODO: DiscoveryCursor // TODO: PolicyMetaInfo 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 90a5158..0aed98f 100644 --- a/anastasis/src/main/java/net/taler/anastasis/reducers/ReducerManager.kt +++ b/anastasis/src/main/java/net/taler/anastasis/reducers/ReducerManager.kt @@ -16,21 +16,35 @@ package net.taler.anastasis.reducers +import android.util.Log import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.encodeToJsonElement +import net.taler.anastasis.Utils import net.taler.anastasis.backend.AnastasisReducerApi +import net.taler.anastasis.models.AuthenticationProviderStatus import net.taler.anastasis.models.ContinentInfo import net.taler.anastasis.models.CountryInfo +import net.taler.anastasis.models.ReducerArgs import net.taler.anastasis.models.ReducerState +import org.json.JSONObject +import kotlin.time.Duration.Companion.seconds class ReducerManager( private val state: MutableStateFlow<ReducerState?>, private val api: AnastasisReducerApi, private val scope: CoroutineScope, ) { + private companion object { + const val PROVIDER_SYNC_PERIOD = 20 + } + + private var providerSyncingJob: Job? = null + // TODO: error handling! fun startBackup() = scope.launch { @@ -75,7 +89,65 @@ class ReducerManager( fun enterUserAttributes(userAttributes: Map<String, String>) = scope.launch { state.value?.let { initialState -> api.reduceAction(initialState, "enter_user_attributes") { - put("identity_attributes", Json.encodeToJsonElement(userAttributes)) + put("identity_attributes", JSONObject(userAttributes)) + }.onSuccess { newState -> + state.value = newState + } + } + } + + fun startSyncingProviders() { + if (providerSyncingJob != null) return + providerSyncingJob = Utils.tickerFlow(PROVIDER_SYNC_PERIOD.seconds) + .onEach { + state.value?.let { initialState -> + // Only run sync when not all providers are synced + if (initialState is ReducerState.Backup) { + initialState.authenticationProviders?.flatMap { + listOf(it.value) + }?.fold(false) { a, b -> + a || (b !is AuthenticationProviderStatus.Ok) + }?.let { sync -> + if (!sync) { + Log.d("ReducerManager", "All providers are synced") + return@onEach + } + } + } + Log.d("ReducerManager", "Syncing providers...") + api.reduceAction(initialState, "sync_providers") + .onSuccess { newState -> + state.value = newState + } + .onError { + Log.d("ReducerManager", "Sync error: $it") + } + } + } + .catch { + Log.d("ReducerManager", "Could not sync providers") + } + .launchIn(scope) + } + + fun stopSyncingProviders() { + providerSyncingJob?.cancel() + providerSyncingJob = null + } + + fun addAuthentication(args: ReducerArgs.AddAuthentication) = scope.launch { + state.value?.let { initialState -> + api.reduceAction(initialState, "add_authentication", args) + .onSuccess { newState -> + state.value = newState + } + } + } + + fun deleteAuthentication(index: Int) = scope.launch { + state.value?.let { initialState -> + api.reduceAction(initialState, "delete_authentication") { + put("index", index) }.onSuccess { newState -> state.value = newState } diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/backup/EditMethodDialog.kt b/anastasis/src/main/java/net/taler/anastasis/ui/backup/EditMethodDialog.kt new file mode 100644 index 0000000..6d30443 --- /dev/null +++ b/anastasis/src/main/java/net/taler/anastasis/ui/backup/EditMethodDialog.kt @@ -0,0 +1,114 @@ +/* + * 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.backup + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.res.stringResource +import net.taler.anastasis.R +import net.taler.anastasis.Utils +import net.taler.anastasis.models.AuthMethod + +@Composable +fun EditMethodDialog( + type: String? = null, + method: AuthMethod? = null, + onMethodEdited: (method: AuthMethod) -> Unit, + onCancel: () -> Unit, +) { + var localMethod by remember { mutableStateOf(method?.copy( + challenge = Utils.decodeBase32(method.challenge), + )) } + AlertDialog( + onDismissRequest = onCancel, + title = { Text(stringResource(R.string.add_challenge)) }, + text = { + when(type ?: method?.type) { + "question" -> EditQuestionForm( + method = localMethod, + onMethodEdited = { + localMethod = it + }, + ) + } + }, + dismissButton = { + TextButton(onClick = { + onCancel() + }) { + Text(stringResource(R.string.cancel)) + } + }, + confirmButton = { + TextButton(onClick = { + localMethod?.let { onMethodEdited( + it.copy( + challenge = Utils.encodeBase32(it.challenge) + ) + ) } + }) { + Text(stringResource(R.string.add)) + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EditQuestionForm( + method: AuthMethod? = null, + onMethodEdited: (method: AuthMethod) -> Unit, +) { + val localMethod = method ?: AuthMethod( + type = "question", + instructions = "", + challenge = "", + mimeType = "plain/text", + ) + + Column { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = localMethod.instructions, + onValueChange = { + onMethodEdited(localMethod.copy(instructions = it)) + }, + label = { Text(stringResource(R.string.question)) }, + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = localMethod.challenge, + onValueChange = { + onMethodEdited(localMethod.copy(challenge = it)) + }, + label = { Text(stringResource(R.string.answer)) }, + ) + } + +}
\ No newline at end of file diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/backup/SelectAuthMethodsScreen.kt b/anastasis/src/main/java/net/taler/anastasis/ui/backup/SelectAuthMethodsScreen.kt new file mode 100644 index 0000000..3bdd129 --- /dev/null +++ b/anastasis/src/main/java/net/taler/anastasis/ui/backup/SelectAuthMethodsScreen.kt @@ -0,0 +1,254 @@ +/* + * 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.backup + +import android.util.Log +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountBalance +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Mail +import androidx.compose.material.icons.filled.QuestionMark +import androidx.compose.material.icons.filled.Sms +import androidx.compose.material.icons.filled.Token +import androidx.compose.material3.Divider +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import net.taler.anastasis.R +import net.taler.anastasis.Utils +import net.taler.anastasis.models.AuthMethod +import net.taler.anastasis.models.AuthenticationProviderStatus +import net.taler.anastasis.models.ReducerArgs +import net.taler.anastasis.models.ReducerState +import net.taler.anastasis.ui.reusable.components.ActionCard +import net.taler.anastasis.ui.reusable.pages.WizardPage +import net.taler.anastasis.ui.theme.LocalSpacing +import net.taler.anastasis.viewmodels.ReducerViewModel + +@Composable +fun SelectAuthMethodsScreen( + viewModel: ReducerViewModel = hiltViewModel(), +) { + val reducerState by viewModel.reducerState.collectAsState() + + val authProviders = when (val state = reducerState) { + is ReducerState.Backup -> state.authenticationProviders + else -> error("invalid reducer state type") + } ?: emptyMap() + + // Get only methods of providers with "ok" status + val availableMethods = authProviders.flatMap { entry -> + if (entry.value is AuthenticationProviderStatus.Ok) { + (entry.value as AuthenticationProviderStatus.Ok).methods.map { it.type } + } else emptyList() + }.distinct() + + val selectedMethods = when (val state = reducerState) { + is ReducerState.Backup -> state.authenticationMethods + else -> error("invalid reducer state type") + } ?: emptyList() + + var showEditDialog by remember { mutableStateOf(false) } + var methodType by remember { mutableStateOf<String?>(null) } + var method by remember { mutableStateOf<AuthMethod?>(null) } + + val reset = { + showEditDialog = false + methodType = null + method = null + } + + if (showEditDialog) { + EditMethodDialog( + type = methodType, + method = method, + onCancel = { + reset() + }, + onMethodEdited = { + reset() + Log.d("onMethodEdited", it.challenge) + viewModel.reducerManager.addAuthentication( + ReducerArgs.AddAuthentication(it) + ) + } + ) + } + + WizardPage( + title = stringResource(R.string.select_auth_methods_title), + onBackClicked = { + viewModel.goHome() + }, + onPrevClicked = { + viewModel.reducerManager.back() + }, + ) { + AuthMethods( + availableMethods = availableMethods, + selectedMethods = selectedMethods, + onAddMethod = { + methodType = it + showEditDialog = true + }, + onDeleteMethod = { + viewModel.reducerManager.deleteAuthentication(it) + }, + ) + } +} + +@Composable +private fun AuthMethods( + availableMethods: List<String>, + selectedMethods: List<AuthMethod>, + onAddMethod: (type: String) -> Unit, + onDeleteMethod: (index: Int) -> Unit, +) { + LazyColumn( + modifier = Modifier + .padding(LocalSpacing.current.medium) + .fillMaxWidth(), + verticalArrangement = Arrangement.Top, + ) { + items(items = availableMethods) { method -> + when (method) { + "question" -> ActionCard( + modifier = Modifier + .padding(bottom = LocalSpacing.current.small) + .fillMaxWidth(), + icon = { Icon(Icons.Default.QuestionMark, contentDescription = null) }, + headline = stringResource(R.string.auth_method_question) + ) { onAddMethod("question") } + "sms" -> ActionCard( + modifier = Modifier + .padding(bottom = LocalSpacing.current.small) + .fillMaxWidth(), + icon = { Icon(Icons.Default.Sms, contentDescription = null) }, + headline = stringResource(R.string.auth_method_sms) + ) { onAddMethod("sms") } + "email" -> ActionCard( + modifier = Modifier + .padding(bottom = LocalSpacing.current.small) + .fillMaxWidth(), + icon = { Icon(Icons.Default.Email, contentDescription = null) }, + headline = stringResource(R.string.auth_method_email) + ) { onAddMethod("email") } + "iban" -> ActionCard( + modifier = Modifier + .padding(bottom = LocalSpacing.current.small) + .fillMaxWidth(), + icon = { Icon(Icons.Default.AccountBalance, contentDescription = null) }, + headline = stringResource(R.string.auth_method_question) + ) { onAddMethod("iban") } + "mail" -> ActionCard( + modifier = Modifier + .padding(bottom = LocalSpacing.current.small) + .fillMaxWidth(), + icon = { Icon(Icons.Default.Mail, contentDescription = null) }, + headline = stringResource(R.string.auth_method_mail) + ) { onAddMethod("mail") } + "totp" -> ActionCard( + modifier = Modifier + .padding(bottom = LocalSpacing.current.small) + .fillMaxWidth(), + icon = { Icon(Icons.Default.Token, contentDescription = null) }, + headline = stringResource(R.string.auth_method_totp) + ) { onAddMethod("totp") } + else -> {} + } + } + + item { + Divider(Modifier.padding(bottom = LocalSpacing.current.small)) + } + + items(count = selectedMethods.size) { i -> + ChallengeCard( + modifier = Modifier + .padding(bottom = LocalSpacing.current.small) + .fillMaxWidth(), + authMethod = selectedMethods[i], + onDelete = { onDeleteMethod(i) }, + ) + } + } +} + +@Composable +private fun ChallengeCard( + modifier: Modifier = Modifier, + authMethod: AuthMethod, + onDelete: () -> Unit, +) { + ElevatedCard( + modifier = modifier, + ) { + Column(modifier = Modifier.padding(LocalSpacing.current.medium)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(when (authMethod.type) { + "question" -> Icons.Default.QuestionMark + "sms" -> Icons.Default.Sms + "email" -> Icons.Default.Email + "iban" -> Icons.Default.AccountBalance + "mail" -> Icons.Default.Mail + "totp" -> Icons.Default.Token + else -> error("unknown auth method") + }, contentDescription = null) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text(authMethod.instructions, style = MaterialTheme.typography.titleMedium) + Text( + text = Utils.decodeBase32(authMethod.challenge), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 5.dp) + ) + } + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = onDelete) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.delete), + ) + } + } + } + } +}
\ No newline at end of file diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectContinentScreen.kt b/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectContinentScreen.kt index b8ec266..3fabd1a 100644 --- a/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectContinentScreen.kt +++ b/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectContinentScreen.kt @@ -20,10 +20,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -49,21 +45,18 @@ fun SelectContinentScreen( val continents = when (val state = reducerState) { is ReducerState.Backup -> state.continents is ReducerState.Recovery -> state.continents - else -> null + else -> error("invalid reducer state type") } ?: emptyList() var selectedContinent by remember { mutableStateOf<ContinentInfo?>(null) } WizardPage( - title = stringResource(R.string.select_country_title), - navigationIcon = { - IconButton(onClick = { - viewModel.goHome() - }) { - Icon(Icons.Default.ArrowBack, "back") - } - }, + title = stringResource(R.string.select_continent_title), showPrev = false, + enableNext = selectedContinent != null, + onBackClicked = { + viewModel.goHome() + }, onNextClicked = { selectedContinent?.let { viewModel.reducerManager.selectContinent(it) diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectCountryScreen.kt b/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectCountryScreen.kt index 1c4fa49..639519f 100644 --- a/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectCountryScreen.kt +++ b/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectCountryScreen.kt @@ -20,10 +20,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -49,19 +45,16 @@ fun SelectCountryScreen( val countries = when (val state = reducerState) { is ReducerState.Backup -> state.countries is ReducerState.Recovery -> state.countries - else -> null + else -> error("invalid reducer state type") } ?: emptyList() var selectedCountry by remember { mutableStateOf<CountryInfo?>(null) } WizardPage( title = stringResource(R.string.select_country_title), - navigationIcon = { - IconButton(onClick = { - viewModel.goHome() - }) { - Icon(Icons.Default.ArrowBack, "back") - } + enableNext = selectedCountry != null, + onBackClicked = { + viewModel.goHome() }, onPrevClicked = { viewModel.reducerManager.back() diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectUserAttributesScreen.kt b/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectUserAttributesScreen.kt index 6fa9f97..51d971f 100644 --- a/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectUserAttributesScreen.kt +++ b/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectUserAttributesScreen.kt @@ -24,11 +24,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -58,19 +54,15 @@ fun SelectUserAttributesScreen( val userAttributes = when (val state = reducerState) { is ReducerState.Backup -> state.requiredAttributes is ReducerState.Recovery -> state.requiredAttributes - else -> null + else -> error("invalid reducer state type") } ?: emptyList() val values = remember { mutableStateMapOf<String, String>() } WizardPage( title = stringResource(R.string.select_user_attributes_title), - navigationIcon = { - IconButton(onClick = { - viewModel.goHome() - }) { - Icon(Icons.Default.ArrowBack, "back") - } + onBackClicked = { + viewModel.goHome() }, onPrevClicked = { viewModel.reducerManager.back() diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/home/HomeScreen.kt b/anastasis/src/main/java/net/taler/anastasis/ui/home/HomeScreen.kt index f79c3a2..cf24c48 100644 --- a/anastasis/src/main/java/net/taler/anastasis/ui/home/HomeScreen.kt +++ b/anastasis/src/main/java/net/taler/anastasis/ui/home/HomeScreen.kt @@ -16,10 +16,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import net.taler.anastasis.R import net.taler.anastasis.ui.reusable.components.ActionCard +import net.taler.anastasis.ui.theme.LocalSpacing import net.taler.anastasis.viewmodels.ReducerViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -39,14 +39,14 @@ fun HomeScreen( Column( modifier = Modifier .padding(it) - .padding(16.dp), + .padding(LocalSpacing.current.medium), verticalArrangement = Arrangement.SpaceEvenly, ) { // Backup ActionCard( modifier = Modifier .weight(1f) - .padding(bottom = 8.dp) + .padding(bottom = LocalSpacing.current.small) .fillMaxWidth(), icon = { Icon(Icons.Outlined.Upload, null) }, headline = stringResource(R.string.backup_secret), @@ -59,7 +59,7 @@ fun HomeScreen( ActionCard( modifier = Modifier .weight(1f) - .padding(bottom = 8.dp) + .padding(bottom = LocalSpacing.current.small) .fillMaxWidth(), icon = { Icon(Icons.Outlined.Download, null) }, headline = stringResource(R.string.recover_secret), @@ -72,7 +72,7 @@ fun HomeScreen( ActionCard( modifier = Modifier .weight(1f) - .padding(bottom = 8.dp) + .padding(bottom = LocalSpacing.current.small) .fillMaxWidth(), icon = { Icon(Icons.Outlined.Restore, null) }, headline = stringResource(R.string.restore_session), diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/ActionCard.kt b/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/ActionCard.kt index 72d3cbb..6eea2ce 100644 --- a/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/ActionCard.kt +++ b/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/ActionCard.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import net.taler.anastasis.ui.theme.LocalSpacing @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -32,15 +33,14 @@ fun ActionCard( ElevatedCard( modifier = modifier, onClick = onClick, - ) { - Column(modifier = Modifier.padding(16.dp)) { + Column(modifier = Modifier.padding(LocalSpacing.current.medium)) { Row(verticalAlignment = Alignment.CenterVertically) { if (icon != null) { icon() Spacer(modifier = Modifier.width(12.dp)) } - Text(headline, style = MaterialTheme.typography.titleLarge) + Text(headline, style = MaterialTheme.typography.titleMedium) } subhead?.let { Text( diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/Form.kt b/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/Form.kt new file mode 100644 index 0000000..acf3fa4 --- /dev/null +++ b/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/Form.kt @@ -0,0 +1,88 @@ +/* + * 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.reusable.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import net.taler.anastasis.R +import net.taler.anastasis.ui.theme.LocalSpacing + +@Composable +fun Form( + modifier: Modifier = Modifier, + button: @Composable () -> Unit, + onCancelClicked: () -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + Column(modifier = modifier) { + Column (content = content) + Spacer(Modifier.height(LocalSpacing.current.medium)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceAround, + ) { + TextButton( + onClick = onCancelClicked, + ) { + Text(stringResource(R.string.cancel)) + Spacer(Modifier.weight(1f)) + button() + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +fun FormPreview() { + Form( + modifier = Modifier.fillMaxWidth(), + button = { + Button(onClick = {}) { + Text("Create") + } + }, + onCancelClicked = {} + ) { + var email by remember { mutableStateOf("") } + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = email, + onValueChange = { email = it }, + label = { Text(stringResource(R.string.question)) }, + ) + } +}
\ No newline at end of file diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/Picker.kt b/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/Picker.kt index 4c649a6..cd8e798 100644 --- a/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/Picker.kt +++ b/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/Picker.kt @@ -49,7 +49,7 @@ fun Picker( onOptionChanged: (String) -> Unit, ) { var filteredOptions by remember { mutableStateOf(options.toList()) } - var inputValue by remember { mutableStateOf(options.first()) } + var inputValue by remember { mutableStateOf("") } val keyboardController = LocalSoftwareKeyboardController.current Column( diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/reusable/pages/WizardPage.kt b/anastasis/src/main/java/net/taler/anastasis/ui/reusable/pages/WizardPage.kt index 77b467a..e6a24d6 100644 --- a/anastasis/src/main/java/net/taler/anastasis/ui/reusable/pages/WizardPage.kt +++ b/anastasis/src/main/java/net/taler/anastasis/ui/reusable/pages/WizardPage.kt @@ -26,17 +26,21 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable 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.compose.ui.unit.dp +import net.taler.anastasis.R @OptIn(ExperimentalMaterial3Api::class) @Composable fun WizardPage( modifier: Modifier = Modifier, title: String, - navigationIcon: @Composable () -> Unit = {}, + enableNext: Boolean = true, + enablePrev: Boolean = true, showNext: Boolean = true, showPrev: Boolean = true, + onBackClicked: () -> Unit = {}, onNextClicked: () -> Unit = {}, onPrevClicked: () -> Unit = {}, content: @Composable () -> Unit, @@ -45,7 +49,11 @@ fun WizardPage( topBar = { LargeTopAppBar( title = { Text(title) }, - navigationIcon = navigationIcon, + navigationIcon = { + IconButton(onClick = onBackClicked) { + Icon(Icons.Default.ArrowBack, stringResource(R.string.back)) + } + } ) }, ) { @@ -64,6 +72,7 @@ fun WizardPage( ) { if (showPrev) { TextButton( + enabled = enablePrev, onClick = onPrevClicked, ) { Icon( @@ -72,7 +81,7 @@ fun WizardPage( modifier = Modifier.size(ButtonDefaults.IconSize), ) Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text("Previous") + Text(stringResource(R.string.previous)) } } @@ -80,9 +89,10 @@ fun WizardPage( if (showNext) { Button( + enabled = enableNext, onClick = onNextClicked, ) { - Text("Next") + Text(stringResource(R.string.next)) Spacer(Modifier.size(ButtonDefaults.IconSpacing)) Icon( Icons.Default.NavigateNext, @@ -101,11 +111,6 @@ fun WizardPage( fun WizardPagePreview() { WizardPage( title = "Title", - navigationIcon = { - IconButton(onClick = {}) { - Icon(Icons.Default.ArrowBack, null) - } - }, ) { Box ( modifier = Modifier.fillMaxSize(), 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 3f5671b..050e0f5 100644 --- a/anastasis/src/main/java/net/taler/anastasis/viewmodels/ReducerViewModel.kt +++ b/anastasis/src/main/java/net/taler/anastasis/viewmodels/ReducerViewModel.kt @@ -46,12 +46,16 @@ class ReducerViewModel @Inject constructor(): ViewModel() { viewModelScope.launch { _reducerState.collect { Log.d("ReducerViewModel", it?.toString() ?: "nothing") + reducerManager.stopSyncingProviders() _navRoute.value = when (it) { is ReducerState.Backup -> when (it.backupState) { BackupStates.ContinentSelecting -> Routes.SelectContinent.route BackupStates.CountrySelecting -> Routes.SelectCountry.route BackupStates.UserAttributesCollecting -> Routes.SelectUserAttributes.route - BackupStates.AuthenticationsEditing -> TODO() + BackupStates.AuthenticationsEditing -> { + reducerManager.startSyncingProviders() + Routes.SelectAuthMethods.route + } BackupStates.PoliciesReviewing -> TODO() BackupStates.SecretEditing -> TODO() BackupStates.TruthsPaying -> TODO() diff --git a/anastasis/src/main/res/values/strings.xml b/anastasis/src/main/res/values/strings.xml index de788ce..627fc50 100644 --- a/anastasis/src/main/res/values/strings.xml +++ b/anastasis/src/main/res/values/strings.xml @@ -8,11 +8,33 @@ <string name="restore_session">Restore session</string> <!-- Shared --> + <string name="back">Go back</string> + <string name="next">Next</string> + <string name="previous">Previous</string> + <string name="add">Add</string> + <string name="edit">Edit</string> + <string name="delete">Delete</string> + <string name="cancel">Cancel</string> + <string name="menu">Menu</string> <string name="country">Country</string> <string name="continent">Continent</string> + <string name="question">Security question</string> + <string name="answer">Answer</string> <!-- Common --> + <string name="select_continent_title">Where do you live?</string> <string name="select_country_title">Where do you live?</string> <string name="select_user_attributes_title">Who are you?</string> + + <!-- Backup --> <string name="select_auth_methods_title">Authentication methods</string> + <string name="auth_method_question">Add a question challenge</string> + <string name="auth_method_sms">Add a SMS challenge</string> + <string name="auth_method_email">Add an e-mail challenge</string> + <string name="auth_method_iban">Add an IBAN provider</string> + <string name="auth_method_mail">Add a physical mail provider</string> + <string name="auth_method_totp">Add a TOTP challenge</string> + <string name="backup_providers">Manage backup providers</string> + <string name="add_challenge">Add challenge</string> + <string name="edit_challenge">Edit challenge</string> </resources>
\ No newline at end of file |