From 1bee6dce57d98ef1de2f76529c714be97e179815 Mon Sep 17 00:00:00 2001 From: Aleksey Ivanovsky Date: Sat, 2 Mar 2024 15:15:12 +0100 Subject: [PATCH] Add OTP url handling to SetupOneTimePassword screen --- app/build.gradle | 8 +- .../domain/otp/OtpParametersValidator.kt | 3 +- .../passnotes/domain/otp/OtpUriFactory.kt | 14 +- .../domain/otp/model/HashAlgorithmType.kt | 8 +- .../presentation/core/compose/Colors.kt | 12 +- .../factory/NoteEditorCellModelFactory.kt | 3 +- .../SetupOneTimePasswordFragment.kt | 1 + .../SetupOneTimePasswordInteractor.kt | 6 + .../SetupOneTimePasswordScreen.kt | 462 ++++++++++++++---- .../SetupOneTimePasswordViewModel.kt | 249 +++++++++- .../model/CustomTabState.kt | 22 + .../model/SetupOneTimePasswordState.kt | 54 +- .../model/SetupOneTimePasswordTab.kt | 6 + .../setupOneTimePassword/model/UrlTabState.kt | 9 + .../com/ivanovsky/passnotes/util/StringExt.kt | 6 + app/src/main/res/layout/cell_otp_property.xml | 2 +- app/src/main/res/values/strings.xml | 2 + 17 files changed, 705 insertions(+), 162 deletions(-) create mode 100644 app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/model/CustomTabState.kt create mode 100644 app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/model/SetupOneTimePasswordTab.kt create mode 100644 app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/model/UrlTabState.kt diff --git a/app/build.gradle b/app/build.gradle index c8c4a6e0..277d2f53 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -174,7 +174,8 @@ android { def koinVersion = '3.5.0' def roomVersion = '2.6.1' -def lifecycleVersion = '2.2.0' +def lifecycleExtensionVersion = '2.2.0' +def lifecycleVersion = '2.7.0' def appCompatVersion = '1.6.1' def androidAnnotationVersion = '1.7.0' def multidexVersion = '2.0.1' @@ -182,6 +183,7 @@ def coroutinesVersion = '1.7.3' def recyclerViewVersion = '1.3.2' def materialVersion = '1.9.0' def constrainLayoutVersion = '2.1.4' +def constraintComposeVersion = '1.0.1' def cardViewVersion = '1.0.0' def coreKtxVersion = '1.10.1' def activityKtxVersion = '1.7.2' @@ -250,7 +252,7 @@ dependencies { implementation "androidx.annotation:annotation:$androidAnnotationVersion" implementation "androidx.constraintlayout:constraintlayout:$constrainLayoutVersion" implementation "androidx.biometric:biometric:$biometricVersion" - implementation "androidx.lifecycle:lifecycle-extensions:$lifecycleVersion" + implementation "androidx.lifecycle:lifecycle-extensions:$lifecycleExtensionVersion" implementation "androidx.core:core-ktx:$coreKtxVersion" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$liveDataKtxVersion" implementation "androidx.activity:activity-ktx:$activityKtxVersion" @@ -260,6 +262,8 @@ dependencies { implementation platform("androidx.compose:compose-bom:2024.02.00") implementation "androidx.compose.material3:material3" implementation "androidx.activity:activity-compose" + implementation "androidx.constraintlayout:constraintlayout-compose:$constraintComposeVersion" + implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion" // Compose preview implementation "androidx.compose.ui:ui-tooling-preview" diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/domain/otp/OtpParametersValidator.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/otp/OtpParametersValidator.kt index 72cdc57a..402ea8b8 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/domain/otp/OtpParametersValidator.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/otp/OtpParametersValidator.kt @@ -1,6 +1,7 @@ package com.ivanovsky.passnotes.domain.otp import com.ivanovsky.passnotes.domain.otp.model.OtpToken +import com.ivanovsky.passnotes.util.removeSpaces object OtpParametersValidator { @@ -17,6 +18,6 @@ object OtpParametersValidator { } fun isSecretValid(secret: String?): Boolean { - return secret != null && secret.trim().isNotEmpty() + return secret != null && secret.removeSpaces().isNotEmpty() } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/domain/otp/OtpUriFactory.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/otp/OtpUriFactory.kt index e0e6241d..f1af7522 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/domain/otp/OtpUriFactory.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/otp/OtpUriFactory.kt @@ -17,6 +17,7 @@ import com.ivanovsky.passnotes.util.StringUtils.QUESTION_MARK import com.ivanovsky.passnotes.util.StringUtils.SLASH import com.ivanovsky.passnotes.util.toIntSafely import com.ivanovsky.passnotes.util.toLongSafely +import java.util.regex.Pattern object OtpUriFactory { @@ -29,6 +30,8 @@ object OtpUriFactory { private const val URL_PARAM_COUNTER = "counter" private const val URL_PARAM_ALGORITHM = "algorithm" + private val OTP_URI_PATTERN = Pattern.compile("otpauth://[th]otp[\\/?][a-zA-Z0-9\\/?&=:%\\.]+") + fun createUri(token: OtpToken): String { return StringBuilder() .apply { @@ -68,8 +71,8 @@ object OtpUriFactory { .toString() } - fun parseUri(otpUri: String): OtpToken? { - if (otpUri.isBlank()) { + fun parseUri(text: String): OtpToken? { + if (text.isBlank()) { return null } @@ -80,7 +83,12 @@ object OtpUriFactory { var period: Int? = null var algorithm: HashAlgorithmType? = null - val uri = Uri.parse(otpUri.trim()) + val trimmedText = text.trim() + if (!OTP_URI_PATTERN.matcher(trimmedText).matches()) { + return null + } + + val uri = Uri.parse(trimmedText) if (uri.scheme?.lowercase() != SCHEME) { return null } diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/domain/otp/model/HashAlgorithmType.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/otp/model/HashAlgorithmType.kt index bc79d68c..96c6192d 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/domain/otp/model/HashAlgorithmType.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/otp/model/HashAlgorithmType.kt @@ -8,8 +8,12 @@ enum class HashAlgorithmType(val rfcName: String) { companion object { fun fromString(name: String): HashAlgorithmType? { - val loweredName = name.lowercase() - return entries.firstOrNull { algorithm -> loweredName == algorithm.name } + return entries.firstOrNull { algorithm -> + name.equals( + algorithm.name, + ignoreCase = true + ) + } } } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/compose/Colors.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/compose/Colors.kt index 22f3410a..3afcba92 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/compose/Colors.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/compose/Colors.kt @@ -30,7 +30,9 @@ data class AppColors( val importantIcon: Color, val diffInsert: Color, val diffDelete: Color, - val diffUpdate: Color + val diffUpdate: Color, + val progress: Color, + val progressSecondary: Color ) val LightAppColors = AppColors( @@ -59,7 +61,9 @@ val LightAppColors = AppColors( importantIcon = Color(0xFFEF5350), diffInsert = Color(0xFFBEFFBB), diffDelete = Color(0xFFFFD5D5), - diffUpdate = Color(0xFFF0EFAA) + diffUpdate = Color(0xFFF0EFAA), + progress = Color(0xFF3F51B5), + progressSecondary = Color(0xFF3F51B5), ) val DarkAppColors = AppColors( @@ -88,5 +92,7 @@ val DarkAppColors = AppColors( importantIcon = Color(0xFF690005), diffInsert = Color(0xFF061E0B), diffDelete = Color(0xFF300406), - diffUpdate = Color(0xFF33331B) + diffUpdate = Color(0xFF33331B), + progress = Color(0xFF2E3856), + progressSecondary = Color(0xFFEADDFF), ) \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/noteEditor/factory/NoteEditorCellModelFactory.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/noteEditor/factory/NoteEditorCellModelFactory.kt index b5eba784..b43df422 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/noteEditor/factory/NoteEditorCellModelFactory.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/noteEditor/factory/NoteEditorCellModelFactory.kt @@ -37,7 +37,8 @@ class NoteEditorCellModelFactory( createUserNameCell(EMPTY), createPasswordCell(EMPTY), createUrlCell(EMPTY), - createNotesCell(EMPTY) + createNotesCell(EMPTY), + createSpaceCell() ) } diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/SetupOneTimePasswordFragment.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/SetupOneTimePasswordFragment.kt index cee43a64..f1952490 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/SetupOneTimePasswordFragment.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/SetupOneTimePasswordFragment.kt @@ -57,6 +57,7 @@ class SetupOneTimePasswordFragment : FragmentWithDoneButton() { viewModel.navigateBack() true } + else -> { super.onOptionsItemSelected(item) } diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/SetupOneTimePasswordInteractor.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/SetupOneTimePasswordInteractor.kt index f952f014..29c170c8 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/SetupOneTimePasswordInteractor.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/SetupOneTimePasswordInteractor.kt @@ -1,6 +1,8 @@ package com.ivanovsky.passnotes.presentation.setupOneTimePassword import com.ivanovsky.passnotes.domain.otp.OtpParametersValidator +import com.ivanovsky.passnotes.domain.otp.OtpUriFactory +import com.ivanovsky.passnotes.util.StringUtils.EMPTY class SetupOneTimePasswordInteractor { @@ -19,4 +21,8 @@ class SetupOneTimePasswordInteractor { fun isSecretValid(secret: String?): Boolean { return OtpParametersValidator.isSecretValid(secret) } + + fun isUrlValid(url: String?): Boolean { + return OtpUriFactory.parseUri(url ?: EMPTY) != null + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/SetupOneTimePasswordScreen.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/SetupOneTimePasswordScreen.kt index cffe2c55..d6150edb 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/SetupOneTimePasswordScreen.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/SetupOneTimePasswordScreen.kt @@ -1,180 +1,369 @@ package com.ivanovsky.passnotes.presentation.setupOneTimePassword +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +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.res.dimensionResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ivanovsky.passnotes.R import com.ivanovsky.passnotes.presentation.core.compose.AppDropdownMenu import com.ivanovsky.passnotes.presentation.core.compose.AppTextField +import com.ivanovsky.passnotes.presentation.core.compose.AppTheme import com.ivanovsky.passnotes.presentation.core.compose.DarkTheme +import com.ivanovsky.passnotes.presentation.core.compose.HeaderTextStyle import com.ivanovsky.passnotes.presentation.core.compose.LightTheme +import com.ivanovsky.passnotes.presentation.core.compose.PrimaryTextStyle import com.ivanovsky.passnotes.presentation.core.compose.ThemedScreenPreview import com.ivanovsky.passnotes.presentation.core.compose.model.InputType +import com.ivanovsky.passnotes.presentation.setupOneTimePassword.model.CustomTabState import com.ivanovsky.passnotes.presentation.setupOneTimePassword.model.SetupOneTimePasswordState +import com.ivanovsky.passnotes.presentation.setupOneTimePassword.model.SetupOneTimePasswordTab +import com.ivanovsky.passnotes.presentation.setupOneTimePassword.model.UrlTabState @Composable fun SetupOneTimePasswordScreen( viewModel: SetupOneTimePasswordViewModel ) { - val state by viewModel.state.collectAsState() + val state by viewModel.state.collectAsStateWithLifecycle(SetupOneTimePasswordState.DEFAULT) SetupOneTimePasswordScreen( state = state, - onTypeSelected = viewModel::onTypeChanged, - onSecretChanged = viewModel::onSecretChanged, - onAlgorithmSelected = viewModel::onAlgorithmSelected, + onTabSelected = viewModel::onTabChanged, + onTypeChanged = viewModel::onTypeChanged, + onAlgorithmChanged = viewModel::onAlgorithmChanged, onPeriodChanged = viewModel::onPeriodChanged, onCounterChanged = viewModel::onCounterChanged, onLengthChanged = viewModel::onLengthChanged, - onSecretVisibilityChanged = viewModel::onSecretVisibilityChanged + onSecretChanged = viewModel::onSecretChanged, + onSecretVisibilityChanged = viewModel::onSecretVisibilityChanged, + onUrlChanged = viewModel::onUrlChanged ) } @Composable private fun SetupOneTimePasswordScreen( state: SetupOneTimePasswordState, - onTypeSelected: (type: String) -> Unit, - onAlgorithmSelected: (algorithm: String) -> Unit, + onTabSelected: (tab: SetupOneTimePasswordTab) -> Unit, + onTypeChanged: (type: String) -> Unit, + onAlgorithmChanged: (algorithm: String) -> Unit, onPeriodChanged: (period: String) -> Unit, onCounterChanged: (counter: String) -> Unit, onLengthChanged: (length: String) -> Unit, onSecretChanged: (secret: String) -> Unit, - onSecretVisibilityChanged: () -> Unit + onSecretVisibilityChanged: () -> Unit, + onUrlChanged: (url: String) -> Unit ) { Column( modifier = Modifier.fillMaxWidth() ) { - AppTextField( - value = state.secret, - label = stringResource(R.string.secret), - error = state.secretError, - inputType = InputType.TEXT, - isPasswordToggleEnabled = true, - isPasswordVisible = state.isSecretVisible, - onPasswordToggleClicked = onSecretVisibilityChanged, - onValueChange = { newSecret -> onSecretChanged.invoke(newSecret) }, - modifier = Modifier - .fillMaxWidth() - .padding( - start = dimensionResource(R.dimen.element_margin), - end = dimensionResource(R.dimen.element_margin), - top = dimensionResource(R.dimen.half_margin) - ) - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding( - start = dimensionResource(R.dimen.element_margin), - end = dimensionResource(R.dimen.element_margin), - top = dimensionResource(R.dimen.element_margin) - ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth() ) { - AppDropdownMenu( - label = stringResource(R.string.type), - options = state.types, - selectedOption = state.selectedType, - onOptionSelected = { newType -> onTypeSelected.invoke(newType) }, + Card( + colors = CardDefaults.cardColors( + containerColor = AppTheme.theme.colors.surface, + ), modifier = Modifier - .padding(end = dimensionResource(R.dimen.half_margin)) - .weight(weight = 0.5f) - ) + .padding( + top = dimensionResource(R.dimen.element_margin), + start = dimensionResource(R.dimen.element_margin), + end = dimensionResource(R.dimen.element_margin) + ) + ) { + ConstraintLayout( + modifier = Modifier + .padding( + vertical = dimensionResource(R.dimen.element_margin), + horizontal = dimensionResource(R.dimen.element_margin) + ) + .sizeIn( + minWidth = 192.dp, + minHeight = 64.dp, + ) + ) { + val (code, progress) = createRefs() - AppDropdownMenu( - label = stringResource(R.string.algorithm), - options = state.algorithms, - selectedOption = state.selectedAlgorithm, - onOptionSelected = { newAlgorithm -> onAlgorithmSelected.invoke(newAlgorithm) }, - modifier = Modifier - .padding(start = dimensionResource(R.dimen.half_margin)) - .weight(weight = 0.5f) - ) + Text( + style = HeaderTextStyle(), + textAlign = TextAlign.Center, + text = state.code, + modifier = Modifier + .constrainAs(code) { + width = Dimension.fillToConstraints + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(parent.start) + if (state.isPeriodProgressVisible) { + end.linkTo(progress.start, margin = 16.dp) + } else { + end.linkTo(parent.end) + } + } + ) + + if (state.isPeriodProgressVisible) { + CircularProgressIndicator( + progress = { state.periodProgress }, + color = AppTheme.theme.colors.progressSecondary, + modifier = Modifier + .size(36.dp) + .constrainAs(progress) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + end.linkTo(parent.end) + } + ) + } + } + } } - Row( + val allTabs = SetupOneTimePasswordTab.entries + val selectedTabIndex = allTabs.indexOf(state.selectedTab) + + TabRow( + selectedTabIndex = selectedTabIndex, + containerColor = AppTheme.theme.colors.background, modifier = Modifier - .fillMaxWidth() .padding( - top = dimensionResource(R.dimen.element_margin), - start = dimensionResource(R.dimen.element_margin), - end = dimensionResource(R.dimen.element_margin) + top = dimensionResource(R.dimen.half_margin) ) ) { - if (state.isPeriodVisible) { - AppTextField( - value = state.period, - label = stringResource(R.string.generation_interval), - error = state.periodError, - inputType = InputType.NUMBER, - maxLength = 3, - isPasswordToggleEnabled = false, - onValueChange = { newPeriod -> onPeriodChanged.invoke(newPeriod) }, - modifier = Modifier - .padding( - end = dimensionResource(R.dimen.half_margin) - ) - .weight(weight = 0.5f) + TabPanel( + selectedTab = state.selectedTab, + tabs = allTabs, + onTabSelected = onTabSelected + ) + } + + when (state.selectedTab) { + SetupOneTimePasswordTab.CUSTOM -> { + CustomTabContent( + state = state.customTabState, + onTypeChanged = onTypeChanged, + onAlgorithmChanged = onAlgorithmChanged, + onPeriodChanged = onPeriodChanged, + onCounterChanged = onCounterChanged, + onLengthChanged = onLengthChanged, + onSecretChanged = onSecretChanged, + onSecretVisibilityChanged = onSecretVisibilityChanged ) } - if (state.isCounterVisible) { - AppTextField( - value = state.counter, - label = stringResource(R.string.counter), - error = state.counterError, - inputType = InputType.NUMBER, - isPasswordToggleEnabled = false, - onValueChange = { newCounter -> onCounterChanged.invoke(newCounter) }, - modifier = Modifier - .padding( - end = dimensionResource(R.dimen.half_margin) - ) - .weight(weight = 0.5f) + SetupOneTimePasswordTab.URL -> { + UrlTabContent( + state = state.urlTabState, + onUrlChanged = onUrlChanged ) } + } + } +} +@Composable +private fun TabPanel( + selectedTab: SetupOneTimePasswordTab, + tabs: List, + onTabSelected: (tab: SetupOneTimePasswordTab) -> Unit +) { + for (tab in tabs) { + Tab( + selected = (tab == selectedTab), + modifier = Modifier + .height(48.dp), + onClick = { onTabSelected.invoke(tab) }, + ) { + val title = when (tab) { + SetupOneTimePasswordTab.CUSTOM -> stringResource(R.string.custom) + SetupOneTimePasswordTab.URL -> stringResource(R.string.url_cap) + } + + Text( + style = PrimaryTextStyle(), + text = title + ) + } + } +} + +@Composable +private fun CustomTabContent( + state: CustomTabState, + onTypeChanged: (type: String) -> Unit, + onAlgorithmChanged: (algorithm: String) -> Unit, + onPeriodChanged: (period: String) -> Unit, + onCounterChanged: (counter: String) -> Unit, + onLengthChanged: (length: String) -> Unit, + onSecretChanged: (secret: String) -> Unit, + onSecretVisibilityChanged: () -> Unit +) { + AppTextField( + value = state.secret, + label = stringResource(R.string.secret), + error = state.secretError, + inputType = InputType.TEXT, + isPasswordToggleEnabled = true, + isPasswordVisible = state.isSecretVisible, + onPasswordToggleClicked = onSecretVisibilityChanged, + onValueChange = { newSecret -> onSecretChanged.invoke(newSecret) }, + modifier = Modifier + .fillMaxWidth() + .padding( + start = dimensionResource(R.dimen.element_margin), + end = dimensionResource(R.dimen.element_margin), + top = dimensionResource(R.dimen.element_margin) + ) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = dimensionResource(R.dimen.element_margin), + end = dimensionResource(R.dimen.element_margin), + top = dimensionResource(R.dimen.element_margin) + ) + ) { + AppDropdownMenu( + label = stringResource(R.string.type), + options = state.types, + selectedOption = state.selectedType, + onOptionSelected = { newType -> onTypeChanged.invoke(newType) }, + modifier = Modifier + .padding(end = dimensionResource(R.dimen.half_margin)) + .weight(weight = 0.5f) + ) + + AppDropdownMenu( + label = stringResource(R.string.algorithm), + options = state.algorithms, + selectedOption = state.selectedAlgorithm, + onOptionSelected = { newAlgorithm -> onAlgorithmChanged.invoke(newAlgorithm) }, + modifier = Modifier + .padding(start = dimensionResource(R.dimen.half_margin)) + .weight(weight = 0.5f) + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + top = dimensionResource(R.dimen.element_margin), + start = dimensionResource(R.dimen.element_margin), + end = dimensionResource(R.dimen.element_margin) + ) + ) { + if (state.isPeriodVisible) { AppTextField( - value = state.length, - label = stringResource(R.string.code_length), - error = state.lengthError, + value = state.period, + label = stringResource(R.string.generation_interval), + error = state.periodError, inputType = InputType.NUMBER, - maxLength = 2, + maxLength = 3, isPasswordToggleEnabled = false, - onValueChange = { newLength -> - onLengthChanged.invoke(newLength) - }, + onValueChange = { newPeriod -> onPeriodChanged.invoke(newPeriod) }, modifier = Modifier .padding( - start = dimensionResource(R.dimen.half_margin) + end = dimensionResource(R.dimen.half_margin) ) .weight(weight = 0.5f) ) } + + if (state.isCounterVisible) { + AppTextField( + value = state.counter, + label = stringResource(R.string.counter), + error = state.counterError, + inputType = InputType.NUMBER, + isPasswordToggleEnabled = false, + onValueChange = { newCounter -> onCounterChanged.invoke(newCounter) }, + modifier = Modifier + .padding( + end = dimensionResource(R.dimen.half_margin) + ) + .weight(weight = 0.5f) + ) + } + + AppTextField( + value = state.length, + label = stringResource(R.string.code_length), + error = state.lengthError, + inputType = InputType.NUMBER, + maxLength = 2, + isPasswordToggleEnabled = false, + onValueChange = { newLength -> + onLengthChanged.invoke(newLength) + }, + modifier = Modifier + .padding( + start = dimensionResource(R.dimen.half_margin) + ) + .weight(weight = 0.5f) + ) } } +@Composable +private fun UrlTabContent( + state: UrlTabState, + onUrlChanged: (url: String) -> Unit +) { + AppTextField( + value = state.url, + label = stringResource(R.string.url_cap), + error = state.urlError, + inputType = InputType.TEXT, + onValueChange = { newUrl -> onUrlChanged.invoke(newUrl) }, + modifier = Modifier + .fillMaxWidth() + .padding( + start = dimensionResource(R.dimen.element_margin), + end = dimensionResource(R.dimen.element_margin), + top = dimensionResource(R.dimen.element_margin) + ) + ) +} + @Preview @Composable fun LightTotpPreview() { ThemedScreenPreview(theme = LightTheme) { SetupOneTimePasswordScreen( - state = newTotpState(), - onTypeSelected = {}, + state = newCustomTotpState(), + onTabSelected = {}, + onTypeChanged = {}, onSecretChanged = {}, - onAlgorithmSelected = {}, + onAlgorithmChanged = {}, onPeriodChanged = {}, onCounterChanged = {}, onLengthChanged = {}, - onSecretVisibilityChanged = {} + onSecretVisibilityChanged = {}, + onUrlChanged = {} ) } } @@ -184,14 +373,35 @@ fun LightTotpPreview() { fun LightHotpPreview() { ThemedScreenPreview(theme = LightTheme) { SetupOneTimePasswordScreen( - state = newHotpState(), - onTypeSelected = {}, + state = newCustomHotpState(), + onTabSelected = {}, + onTypeChanged = {}, onSecretChanged = {}, - onAlgorithmSelected = {}, + onAlgorithmChanged = {}, onPeriodChanged = {}, onCounterChanged = {}, onLengthChanged = {}, - onSecretVisibilityChanged = {} + onSecretVisibilityChanged = {}, + onUrlChanged = {} + ) + } +} + +@Preview +@Composable +fun LightUrlPreview() { + ThemedScreenPreview(theme = LightTheme) { + SetupOneTimePasswordScreen( + state = newUrlTotpState(), + onTabSelected = {}, + onTypeChanged = {}, + onSecretChanged = {}, + onAlgorithmChanged = {}, + onPeriodChanged = {}, + onCounterChanged = {}, + onLengthChanged = {}, + onSecretVisibilityChanged = {}, + onUrlChanged = {} ) } } @@ -201,19 +411,57 @@ fun LightHotpPreview() { fun DarkPreview() { ThemedScreenPreview(theme = DarkTheme) { SetupOneTimePasswordScreen( - state = newTotpState(), - onTypeSelected = {}, + state = newCustomTotpState(), + onTabSelected = {}, + onTypeChanged = {}, onSecretChanged = {}, - onAlgorithmSelected = {}, + onAlgorithmChanged = {}, onPeriodChanged = {}, onCounterChanged = {}, onLengthChanged = {}, - onSecretVisibilityChanged = {} + onSecretVisibilityChanged = {}, + onUrlChanged = {} ) } } -private fun newTotpState() = SetupOneTimePasswordState( +private fun newCustomTotpState() = SetupOneTimePasswordState( + selectedTab = SetupOneTimePasswordTab.CUSTOM, + code = "--- ---", + periodProgress = 0.75f, + isPeriodProgressVisible = true, + customTabState = newTotpState(), + urlTabState = UrlTabState( + url = "", + urlError = null + ) +) + +private fun newCustomHotpState() = SetupOneTimePasswordState( + selectedTab = SetupOneTimePasswordTab.CUSTOM, + code = "--- ---", + periodProgress = 0.75f, + isPeriodProgressVisible = true, + customTabState = newHotpState(), + urlTabState = UrlTabState( + url = "", + urlError = null + ) +) + +private fun newUrlTotpState() = SetupOneTimePasswordState( + selectedTab = SetupOneTimePasswordTab.URL, + code = "--- ---", + periodProgress = 0.75f, + isPeriodProgressVisible = true, + customTabState = newHotpState(), + urlTabState = UrlTabState( + url = "otpauth://totp/Name:Issuer?secret=AAAABBBB&period=30&digits=6", + urlError = null + ) +) + +private fun newTotpState() = CustomTabState( types = listOf("TOTP", "HOTP"), selectedType = "TOTP", secret = "secret", @@ -231,7 +479,7 @@ private fun newTotpState() = SetupOneTimePasswordState( isCounterVisible = false ) -private fun newHotpState() = SetupOneTimePasswordState( +private fun newHotpState() = CustomTabState( types = listOf("TOTP", "HOTP"), selectedType = "TOTP", secret = "secret", @@ -246,5 +494,5 @@ private fun newHotpState() = SetupOneTimePasswordState( counterError = null, lengthError = null, isPeriodVisible = false, - isCounterVisible = true + isCounterVisible = true, ) \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/SetupOneTimePasswordViewModel.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/SetupOneTimePasswordViewModel.kt index 6c7940c2..4c05c7ac 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/SetupOneTimePasswordViewModel.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/SetupOneTimePasswordViewModel.kt @@ -5,6 +5,12 @@ import androidx.lifecycle.ViewModelProvider import com.github.terrakok.cicerone.Router import com.ivanovsky.passnotes.R import com.ivanovsky.passnotes.domain.ResourceProvider +import com.ivanovsky.passnotes.domain.otp.HotpGenerator +import com.ivanovsky.passnotes.domain.otp.OtpCodeFormatter +import com.ivanovsky.passnotes.domain.otp.OtpFlowFactory +import com.ivanovsky.passnotes.domain.otp.OtpGenerator +import com.ivanovsky.passnotes.domain.otp.OtpUriFactory +import com.ivanovsky.passnotes.domain.otp.TotpGenerator import com.ivanovsky.passnotes.domain.otp.model.HashAlgorithmType import com.ivanovsky.passnotes.domain.otp.model.OtpToken import com.ivanovsky.passnotes.domain.otp.model.OtpToken.Companion.DEFAULT_COUNTER @@ -15,11 +21,20 @@ import com.ivanovsky.passnotes.injection.GlobalInjector import com.ivanovsky.passnotes.presentation.Screens.SetupOneTimePasswordScreen import com.ivanovsky.passnotes.presentation.core.ThemeProvider import com.ivanovsky.passnotes.presentation.core.compose.themeFlow +import com.ivanovsky.passnotes.presentation.setupOneTimePassword.model.CustomTabState import com.ivanovsky.passnotes.presentation.setupOneTimePassword.model.SetupOneTimePasswordState +import com.ivanovsky.passnotes.presentation.setupOneTimePassword.model.SetupOneTimePasswordTab +import com.ivanovsky.passnotes.presentation.setupOneTimePassword.model.UrlTabState import com.ivanovsky.passnotes.util.StringUtils.EMPTY +import com.ivanovsky.passnotes.util.removeSpaces import com.ivanovsky.passnotes.util.toIntSafely import com.ivanovsky.passnotes.util.toLongSafely +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import org.koin.core.parameter.parametersOf class SetupOneTimePasswordViewModel( @@ -30,6 +45,10 @@ class SetupOneTimePasswordViewModel( private val args: SetupOneTimePasswordArgs ) : ViewModel() { + private var token: OtpToken? = null + private var generator: OtpGenerator? = null + private var selectedTab = SetupOneTimePasswordTab.CUSTOM + private var url: String = EMPTY private var type = OtpTokenType.TOTP private var secret = EMPTY private var isSecretVisible = false @@ -37,45 +56,98 @@ class SetupOneTimePasswordViewModel( private var period = DEFAULT_PERIOD_IN_SECONDS.toString() private var counter = DEFAULT_COUNTER.toString() private var length = DEFAULT_DIGITS.toString() + private var code = createCodePlaceholder() + private var urlError: String? = null private var secretError: String? = null private var periodError: String? = null private var counterError: String? = null private var lengthError: String? = null val theme = themeFlow(themeProvider) - val state = MutableStateFlow(buildState()) + + private val stateFlow = MutableStateFlow(buildState()) + private val tokenFlow = MutableStateFlow(null) + + val state = combineTransform( + stateFlow, + buildPeriodProgressFlow() + ) { state, progress -> + val newState = state.copy( + periodProgress = progress + ) + + emit(newState) + } fun onSecretChanged(newSecret: String) { secret = newSecret secretError = null + + updateToken() + updateCode() + updateState() } fun onTypeChanged(newType: String) { type = OtpTokenType.fromString(newType) ?: OtpTokenType.TOTP + + updateToken() + updateCode() + updateState() } fun onPeriodChanged(newPeriod: String) { period = newPeriod periodError = validatePeriod(newPeriod) + + updateToken() + updateCode() + updateState() } fun onCounterChanged(newCounter: String) { counter = newCounter counterError = validateCounter(newCounter) + + updateToken() + updateCode() + updateState() } fun onLengthChanged(newLength: String) { length = newLength lengthError = validateLength(newLength) + + updateToken() + updateCode() + updateState() } - fun onAlgorithmSelected(newAlgorithm: String) { + fun onAlgorithmChanged(newAlgorithm: String) { algorithm = HashAlgorithmType.fromString(newAlgorithm) ?: OtpToken.DEFAULT_HASH_ALGORITHM + + updateToken() + updateCode() + + updateState() + } + + fun onUrlChanged(newUrl: String) { + url = newUrl + urlError = if (newUrl.isNotEmpty()) { + validateUrl(newUrl) + } else { + null + } + + updateToken() + updateCode() + updateState() } @@ -85,7 +157,16 @@ class SetupOneTimePasswordViewModel( } fun onDoneClicked() { - secretError = validateSecret(secret) + when (selectedTab) { + SetupOneTimePasswordTab.CUSTOM -> { + secretError = validateSecret(secret) + } + + SetupOneTimePasswordTab.URL -> { + urlError = validateUrl(url) + } + } + updateState() if (hasError()) { @@ -100,10 +181,41 @@ class SetupOneTimePasswordViewModel( } } + fun onTabChanged(tab: SetupOneTimePasswordTab) { + selectedTab = tab + + when (tab) { + SetupOneTimePasswordTab.CUSTOM -> { + + } + + SetupOneTimePasswordTab.URL -> { + + } + } + + updateToken() + updateCode() + + updateState() + } + fun navigateBack() { router.exit() } + private fun buildPeriodProgressFlow(): Flow { + return tokenFlow + .flatMapLatest { token -> + if (token != null && token.type == OtpTokenType.TOTP) { + OtpFlowFactory.createProgressFlow(TotpGenerator(token)) + .map { progress -> progress / 100f } + } else { + flowOf(0f) + } + } + } + private fun validatePeriod(period: String): String? { return if (!interactor.isPeriodValid(period.toIntSafely())) { resourceProvider.getString(R.string.generation_interval_invalid_value_message) @@ -129,22 +241,64 @@ class SetupOneTimePasswordViewModel( } private fun validateSecret(secret: String): String? { - return if (!interactor.isSecretValid(secret)) { - resourceProvider.getString(R.string.empty_value_message) - } else { - null + return when { + secret.isBlank() -> { + resourceProvider.getString(R.string.should_not_be_empty) + } + + !interactor.isSecretValid(secret) -> { + resourceProvider.getString(R.string.invalid_value) + } + + else -> { + null + } + } + } + + private fun validateUrl(url: String): String? { + return when { + url.isBlank() -> { + resourceProvider.getString(R.string.should_not_be_empty) + } + + !interactor.isUrlValid(url) -> { + resourceProvider.getString(R.string.invalid_value) + } + + else -> null } } private fun hasError(): Boolean { + return when (selectedTab) { + SetupOneTimePasswordTab.CUSTOM -> hasCustomTabError() + SetupOneTimePasswordTab.URL -> hasUrlTabError() + } + } + + private fun hasCustomTabError(): Boolean { return secretError != null || periodError != null || counterError != null || lengthError != null } + private fun hasUrlTabError(): Boolean { + return urlError != null + } + private fun buildToken(): OtpToken? { - if (hasError()) { + return when (selectedTab) { + SetupOneTimePasswordTab.CUSTOM -> buildTokenFromCustomParams() + SetupOneTimePasswordTab.URL -> buildTokenFromUrl() + } + } + + private fun buildTokenFromCustomParams(): OtpToken? { + val cleanedSecret = secret.removeSpaces() + + if (hasError() || !interactor.isSecretValid(cleanedSecret)) { return null } @@ -154,7 +308,7 @@ class SetupOneTimePasswordViewModel( type = OtpTokenType.TOTP, name = args.tokenName ?: EMPTY, issuer = args.tokenIssuer ?: EMPTY, - secret = secret.replace(" ", ""), + secret = cleanedSecret, algorithm = algorithm, digits = length.toIntSafely() ?: DEFAULT_DIGITS, counter = null, @@ -167,7 +321,7 @@ class SetupOneTimePasswordViewModel( type = OtpTokenType.HOTP, name = args.tokenName ?: EMPTY, issuer = args.tokenIssuer ?: EMPTY, - secret = secret, + secret = cleanedSecret, algorithm = algorithm, digits = length.toIntSafely() ?: DEFAULT_DIGITS, counter = counter.toLongSafely() ?: DEFAULT_COUNTER, @@ -177,27 +331,70 @@ class SetupOneTimePasswordViewModel( } } + private fun buildTokenFromUrl(): OtpToken? { + if (hasError()) { + return null + } + + return OtpUriFactory.parseUri(url) + } + + private fun updateToken() { + this.token = buildToken() + + tokenFlow.value = token + } + + private fun updateCode() { + val token = this.token + + generator = when (token?.type) { + OtpTokenType.TOTP -> TotpGenerator(token) + OtpTokenType.HOTP -> HotpGenerator(token) + else -> null + } + + code = OtpCodeFormatter.format(generator?.generateCode() ?: createCodePlaceholder()) + } + + private fun createCodePlaceholder(): String { + val length = this.length.toIntSafely() ?: DEFAULT_DIGITS + return "-".repeat(length) + } + private fun updateState() { - state.value = buildState() + stateFlow.value = buildState() } private fun buildState(): SetupOneTimePasswordState { + val token = this.token + return SetupOneTimePasswordState( - secret = secret, - secretError = secretError, - isSecretVisible = isSecretVisible, - types = createTypeNames(OtpTokenType.entries), - selectedType = type.toReadableString(), - algorithms = createAlgorithmNames(HashAlgorithmType.entries), - selectedAlgorithm = algorithm.toReadableString(), - period = period, - periodError = periodError, - counter = counter, - counterError = counterError, - length = length, - lengthError = lengthError, - isPeriodVisible = (type == OtpTokenType.TOTP), - isCounterVisible = (type == OtpTokenType.HOTP) + selectedTab = selectedTab, + code = code, + periodProgress = 0f, + isPeriodProgressVisible = (token != null && token.type == OtpTokenType.TOTP), + customTabState = CustomTabState( + secret = secret, + secretError = secretError, + isSecretVisible = isSecretVisible, + types = createTypeNames(OtpTokenType.entries), + selectedType = type.toReadableString(), + algorithms = createAlgorithmNames(HashAlgorithmType.entries), + selectedAlgorithm = algorithm.toReadableString(), + period = period, + periodError = periodError, + counter = counter, + counterError = counterError, + length = length, + lengthError = lengthError, + isPeriodVisible = (type == OtpTokenType.TOTP), + isCounterVisible = (type == OtpTokenType.HOTP), + ), + urlTabState = UrlTabState( + url = url, + urlError = urlError + ) ) } diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/model/CustomTabState.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/model/CustomTabState.kt new file mode 100644 index 00000000..7ad90ec8 --- /dev/null +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/model/CustomTabState.kt @@ -0,0 +1,22 @@ +package com.ivanovsky.passnotes.presentation.setupOneTimePassword.model + +import androidx.compose.runtime.Immutable + +@Immutable +data class CustomTabState( + val secret: String, + val secretError: String?, + val isSecretVisible: Boolean, + val types: List, + val selectedType: String, + val algorithms: List, + val selectedAlgorithm: String, + val period: String, + val periodError: String?, + val counter: String, + val counterError: String?, + val length: String, + val lengthError: String?, + val isPeriodVisible: Boolean, + val isCounterVisible: Boolean, +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/model/SetupOneTimePasswordState.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/model/SetupOneTimePasswordState.kt index e7443161..ea5f0d48 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/model/SetupOneTimePasswordState.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/model/SetupOneTimePasswordState.kt @@ -4,19 +4,41 @@ import androidx.compose.runtime.Immutable @Immutable data class SetupOneTimePasswordState( - val secret: String, - val secretError: String?, - val isSecretVisible: Boolean, - val types: List, - val selectedType: String, - val algorithms: List, - val selectedAlgorithm: String, - val period: String, - val periodError: String?, - val counter: String, - val counterError: String?, - val length: String, - val lengthError: String?, - val isPeriodVisible: Boolean, - val isCounterVisible: Boolean -) \ No newline at end of file + val selectedTab: SetupOneTimePasswordTab, + val code: String, + val periodProgress: Float, + val isPeriodProgressVisible: Boolean, + val customTabState: CustomTabState, + val urlTabState: UrlTabState +) { + + companion object { + val DEFAULT = SetupOneTimePasswordState( + selectedTab = SetupOneTimePasswordTab.CUSTOM, + code = "", + periodProgress = 0f, + isPeriodProgressVisible = false, + customTabState = CustomTabState( + secret = "", + secretError = null, + isSecretVisible = false, + types = emptyList(), + selectedType = "", + algorithms = emptyList(), + selectedAlgorithm = "", + period = "", + periodError = null, + counter = "", + counterError = null, + length = "", + lengthError = null, + isPeriodVisible = false, + isCounterVisible = false + ), + urlTabState = UrlTabState( + url = "", + urlError = null, + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/model/SetupOneTimePasswordTab.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/model/SetupOneTimePasswordTab.kt new file mode 100644 index 00000000..b285dcd0 --- /dev/null +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/model/SetupOneTimePasswordTab.kt @@ -0,0 +1,6 @@ +package com.ivanovsky.passnotes.presentation.setupOneTimePassword.model + +enum class SetupOneTimePasswordTab { + CUSTOM, + URL +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/model/UrlTabState.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/model/UrlTabState.kt new file mode 100644 index 00000000..f3630642 --- /dev/null +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/setupOneTimePassword/model/UrlTabState.kt @@ -0,0 +1,9 @@ +package com.ivanovsky.passnotes.presentation.setupOneTimePassword.model + +import androidx.compose.runtime.Immutable + +@Immutable +data class UrlTabState( + val url: String, + val urlError: String?, +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/util/StringExt.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/util/StringExt.kt index b3fa82f4..df9ab3ed 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/util/StringExt.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/util/StringExt.kt @@ -7,6 +7,12 @@ import java.util.Date import java.util.Locale import java.util.UUID +fun String.removeSpaces(): String { + return this.replace(" ", "") + .replace("\n", "") + .replace("\t", "") +} + fun String.toIntSafely(): Int? { return try { Integer.parseInt(this) diff --git a/app/src/main/res/layout/cell_otp_property.xml b/app/src/main/res/layout/cell_otp_property.xml index 9b55e834..f3c58428 100644 --- a/app/src/main/res/layout/cell_otp_property.xml +++ b/app/src/main/res/layout/cell_otp_property.xml @@ -57,8 +57,8 @@ android:layout_height="wrap_content" android:layout_marginEnd="@dimen/half_margin" android:progress="@{viewModel.progress}" - app:indicatorSize="@dimen/small_progress_bar_size" app:indicatorColor="?attr/kpProgressSecondaryColor" + app:indicatorSize="@dimen/small_progress_bar_size" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ce760cd5..2ebf8fcf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -355,5 +355,7 @@ Should be a positive value or 0 Should be a value between 4 and 18 Should not be an empty value + Custom + Invalid value