From fa03ca0ad7e556e60a13a355cbbd44675c135f88 Mon Sep 17 00:00:00 2001 From: Harsh Shandilya Date: Wed, 5 Jul 2023 14:20:22 +0530 Subject: feat(ui): add a dedicated Compose screen for editing passwords --- .../app/passwordstore/ui/crypto/DecryptScreen.kt | 175 --------------------- .../passwordstore/ui/crypto/EditPasswordScreen.kt | 100 ++++++++++++ .../passwordstore/ui/crypto/ViewPasswordScreen.kt | 130 +++++++++++++++ app/src/main/res/values/strings.xml | 1 + .../app/passwordstore/ui/compose/PasswordField.kt | 24 ++- .../src/main/res/drawable/ic_content_copy.xml | 15 ++ 6 files changed, 269 insertions(+), 176 deletions(-) delete mode 100644 app/src/main/java/app/passwordstore/ui/crypto/DecryptScreen.kt create mode 100644 app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt create mode 100644 app/src/main/java/app/passwordstore/ui/crypto/ViewPasswordScreen.kt create mode 100644 ui-compose/src/main/res/drawable/ic_content_copy.xml diff --git a/app/src/main/java/app/passwordstore/ui/crypto/DecryptScreen.kt b/app/src/main/java/app/passwordstore/ui/crypto/DecryptScreen.kt deleted file mode 100644 index 2e25b56f..00000000 --- a/app/src/main/java/app/passwordstore/ui/crypto/DecryptScreen.kt +++ /dev/null @@ -1,175 +0,0 @@ -package app.passwordstore.ui.crypto - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.capitalize -import androidx.compose.ui.text.intl.Locale -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import app.passwordstore.R -import app.passwordstore.data.passfile.PasswordEntry -import app.passwordstore.ui.APSAppBar -import app.passwordstore.ui.compose.PasswordField -import app.passwordstore.ui.compose.theme.APSThemePreview -import app.passwordstore.util.time.UserClock -import app.passwordstore.util.totp.UriTotpFinder -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking - -/** - * Composable to show a [PasswordEntry]. It can be used for both read-only usage (decrypt screen) or - * read-write (encrypt screen) to allow sharing UI logic for both these screens and deferring all - * the cryptographic aspects to its parent. - * - * When [readOnly] is `true`, the Composable assumes that we're showcasing the provided [entry] to - * the user and does not offer any edit capabilities. - * - * When [readOnly] is `false`, the [TextField]s are rendered editable but currently do not pass up - * their "updated" state to anything. This will be changed in later commits. - */ -@Composable -fun PasswordEntryScreen( - entryName: String, - entry: PasswordEntry, - readOnly: Boolean, - onNavigateUp: () -> Unit, - modifier: Modifier = Modifier, -) { - Scaffold( - topBar = { - APSAppBar( - title = entryName, - navigationIcon = painterResource(R.drawable.ic_arrow_back_black_24dp), - onNavigationIconClick = onNavigateUp, - backgroundColor = MaterialTheme.colorScheme.surface, - ) - }, - ) { paddingValues -> - Box(modifier = modifier.padding(paddingValues)) { - Column(modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp).fillMaxSize()) { - if (entry.password != null) { - PasswordField( - value = entry.password!!, - label = stringResource(R.string.password), - initialVisibility = false, - readOnly = readOnly, - modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(), - ) - } - if (entry.hasTotp() && readOnly) { - val totp by entry.totp.collectAsState(runBlocking { entry.totp.first() }) - TextField( - value = totp.value, - onValueChange = {}, - readOnly = true, - label = { Text("OTP (expires in ${totp.remainingTime.inWholeSeconds}s)") }, - trailingIcon = { CopyButton({ totp.value }) }, - modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(), - ) - } - if (entry.username != null && readOnly) { - TextField( - value = entry.username!!, - onValueChange = {}, - readOnly = true, - label = { Text(stringResource(R.string.username)) }, - trailingIcon = { CopyButton({ entry.username!! }) }, - modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(), - ) - } - ExtraContent(entry = entry, readOnly = readOnly) - } - } - } -} - -@Composable -private fun ExtraContent( - entry: PasswordEntry, - readOnly: Boolean, - modifier: Modifier = Modifier, -) { - if (readOnly) { - entry.extraContent.forEach { (label, value) -> - TextField( - value = value, - onValueChange = {}, - readOnly = true, - label = { Text(label.capitalize(Locale.current)) }, - trailingIcon = { CopyButton({ value }) }, - modifier = modifier.padding(bottom = 8.dp).fillMaxWidth(), - ) - } - } else { - TextField( - value = entry.extraContentString, - onValueChange = {}, - readOnly = false, - label = { Text("Extra content") }, - modifier = modifier.fillMaxWidth(), - ) - } -} - -@Composable -private fun CopyButton( - textToCopy: () -> String, - modifier: Modifier = Modifier, -) { - val clipboard = LocalClipboardManager.current - IconButton( - onClick = { clipboard.setText(AnnotatedString(textToCopy())) }, - modifier = modifier, - ) { - Icon( - painter = painterResource(R.drawable.ic_content_copy), - contentDescription = stringResource(R.string.copy_password), - ) - } -} - -@Preview -@Composable -private fun PasswordEntryPreview() { - APSThemePreview { - PasswordEntryScreen( - entryName = "Test Entry", - entry = createTestEntry(), - readOnly = true, - onNavigateUp = {}, - ) - } -} - -private fun createTestEntry() = - PasswordEntry( - UserClock(), - UriTotpFinder(), - """ - |My Password - |otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30 - |login: msfjarvis - |URL: example.com - """ - .trimMargin() - .encodeToByteArray() - ) diff --git a/app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt b/app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt new file mode 100644 index 00000000..9c3707f7 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt @@ -0,0 +1,100 @@ +package app.passwordstore.ui.crypto + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.passwordstore.R +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.ui.APSAppBar +import app.passwordstore.ui.compose.PasswordField +import app.passwordstore.ui.compose.theme.APSThemePreview +import app.passwordstore.util.time.UserClock +import app.passwordstore.util.totp.UriTotpFinder + +/** Composable to show allow editing an existing [PasswordEntry]. */ +@Composable +fun EditPasswordScreen( + entryName: String, + entry: PasswordEntry, + onNavigateUp: () -> Unit, + @Suppress("UNUSED_PARAMETER") onSave: (PasswordEntry) -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + topBar = { + APSAppBar( + title = entryName, + navigationIcon = painterResource(R.drawable.ic_arrow_back_black_24dp), + onNavigationIconClick = onNavigateUp, + backgroundColor = MaterialTheme.colorScheme.surface, + ) + }, + ) { paddingValues -> + Box(modifier = modifier.padding(paddingValues)) { + Column(modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp).fillMaxSize()) { + if (entry.password != null) { + PasswordField( + value = entry.password!!, + label = stringResource(R.string.password), + initialVisibility = false, + modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(), + ) + } + ExtraContent(entry = entry) + } + } + } +} + +@Composable +private fun ExtraContent( + entry: PasswordEntry, + modifier: Modifier = Modifier, +) { + TextField( + value = entry.extraContentString, + onValueChange = {}, + label = { Text("Extra content") }, + modifier = modifier.fillMaxWidth(), + ) +} + +@Preview +@Composable +private fun EditPasswordScreenPreview() { + APSThemePreview { + EditPasswordScreen( + entryName = "Test Entry", + entry = createTestEntry(), + onNavigateUp = {}, + onSave = {}, + ) + } +} + +private fun createTestEntry() = + PasswordEntry( + UserClock(), + UriTotpFinder(), + """ + |My Password + |otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30 + |login: msfjarvis + |URL: example.com + """ + .trimMargin() + .encodeToByteArray() + ) diff --git a/app/src/main/java/app/passwordstore/ui/crypto/ViewPasswordScreen.kt b/app/src/main/java/app/passwordstore/ui/crypto/ViewPasswordScreen.kt new file mode 100644 index 00000000..2ebdc3a9 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/crypto/ViewPasswordScreen.kt @@ -0,0 +1,130 @@ +package app.passwordstore.ui.crypto + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.capitalize +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.passwordstore.R +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.ui.APSAppBar +import app.passwordstore.ui.compose.CopyButton +import app.passwordstore.ui.compose.PasswordField +import app.passwordstore.ui.compose.theme.APSThemePreview +import app.passwordstore.util.time.UserClock +import app.passwordstore.util.totp.UriTotpFinder +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +/** Composable to show a decrypted [PasswordEntry]. */ +@Composable +fun ViewPasswordScreen( + entryName: String, + entry: PasswordEntry, + onNavigateUp: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + topBar = { + APSAppBar( + title = entryName, + navigationIcon = painterResource(R.drawable.ic_arrow_back_black_24dp), + onNavigationIconClick = onNavigateUp, + backgroundColor = MaterialTheme.colorScheme.surface, + ) + }, + ) { paddingValues -> + Box(modifier = modifier.padding(paddingValues)) { + Column(modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp).fillMaxSize()) { + if (entry.password != null) { + PasswordField( + value = entry.password!!, + label = stringResource(R.string.password), + initialVisibility = false, + readOnly = true, + modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(), + ) + } + if (entry.hasTotp()) { + val totp by entry.totp.collectAsState(runBlocking { entry.totp.first() }) + TextField( + value = totp.value, + onValueChange = {}, + readOnly = true, + label = { Text("OTP (expires in ${totp.remainingTime.inWholeSeconds}s)") }, + trailingIcon = { CopyButton(totp.value, R.string.copy_label) }, + modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(), + ) + } + if (entry.username != null) { + TextField( + value = entry.username!!, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.username)) }, + trailingIcon = { CopyButton(entry.username!!, R.string.copy_label) }, + modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(), + ) + } + ExtraContent(entry = entry) + } + } + } +} + +@Composable +private fun ExtraContent( + entry: PasswordEntry, + modifier: Modifier = Modifier, +) { + entry.extraContent.forEach { (label, value) -> + TextField( + value = value, + onValueChange = {}, + readOnly = true, + label = { Text(label.capitalize(Locale.current)) }, + trailingIcon = { CopyButton(value, R.string.copy_label) }, + modifier = modifier.padding(bottom = 8.dp).fillMaxWidth(), + ) + } +} + +@Preview +@Composable +private fun ViewPasswordScreenPreview() { + APSThemePreview { + ViewPasswordScreen( + entryName = "Test Entry", + entry = createTestEntry(), + onNavigateUp = {}, + ) + } +} + +private fun createTestEntry() = + PasswordEntry( + UserClock(), + UriTotpFinder(), + """ + |My Password + |otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30 + |login: msfjarvis + |URL: example.com + """ + .trimMargin() + .encodeToByteArray() + ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 38b7dbc3..deb80de3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -88,6 +88,7 @@ Search Password Username + Copy Edit password Copy password Share as plaintext diff --git a/ui-compose/src/main/kotlin/app/passwordstore/ui/compose/PasswordField.kt b/ui-compose/src/main/kotlin/app/passwordstore/ui/compose/PasswordField.kt index ee202486..d2a04777 100644 --- a/ui-compose/src/main/kotlin/app/passwordstore/ui/compose/PasswordField.kt +++ b/ui-compose/src/main/kotlin/app/passwordstore/ui/compose/PasswordField.kt @@ -1,5 +1,6 @@ package app.passwordstore.ui.compose +import androidx.annotation.StringRes import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text @@ -10,7 +11,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation @@ -19,8 +23,8 @@ public fun PasswordField( value: String, label: String, initialVisibility: Boolean, - readOnly: Boolean, modifier: Modifier = Modifier, + readOnly: Boolean = false, ) { var visible by remember { mutableStateOf(initialVisibility) } TextField( @@ -58,3 +62,21 @@ private fun ToggleButton( ) } } + +@Composable +public fun CopyButton( + textToCopy: String, + @StringRes buttonLabelRes: Int, + modifier: Modifier = Modifier, +) { + val clipboard = LocalClipboardManager.current + IconButton( + onClick = { clipboard.setText(AnnotatedString(textToCopy)) }, + modifier = modifier, + ) { + Icon( + painter = painterResource(R.drawable.ic_content_copy), + contentDescription = stringResource(buttonLabelRes), + ) + } +} diff --git a/ui-compose/src/main/res/drawable/ic_content_copy.xml b/ui-compose/src/main/res/drawable/ic_content_copy.xml new file mode 100644 index 00000000..8afc9846 --- /dev/null +++ b/ui-compose/src/main/res/drawable/ic_content_copy.xml @@ -0,0 +1,15 @@ + + + + + -- cgit v1.2.3