add user ID input field (password creation/edit), may fix #1458 (#3161)

* add user ID input field (password creation/edit), may fix #1458

* fix: revert change to username label

* refactor: rework FieldItem to drop hard-coded strings

* refactor: drop unnecessary `.apply`

---------

Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
Alexander Grahn 2024-08-11 22:27:20 +02:00 committed by GitHub
parent d62516399e
commit f803465e40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 117 additions and 54 deletions

View file

@ -7,35 +7,45 @@ package app.passwordstore.data.password
import app.passwordstore.data.passfile.Totp import app.passwordstore.data.passfile.Totp
class FieldItem(val key: String, val value: String, val action: ActionType) { class FieldItem
private constructor(
val type: ItemType,
val label: String,
val value: String,
val action: ActionType,
) {
enum class ActionType { enum class ActionType {
COPY, COPY,
HIDE, HIDE,
} }
enum class ItemType(val type: String, val label: String) { enum class ItemType() {
USERNAME("Username", "Username"), USERNAME,
PASSWORD("Password", "Password"), PASSWORD,
OTP("OTP", "OTP (expires in %ds)"), OTP,
FREEFORM,
} }
companion object { companion object {
fun createOtpField(label: String, totp: Totp): FieldItem {
// Extra helper methods
fun createOtpField(totp: Totp): FieldItem {
return FieldItem( return FieldItem(
ItemType.OTP.label.format(totp.remainingTime.inWholeSeconds), ItemType.OTP,
label.format(totp.remainingTime.inWholeSeconds),
totp.value, totp.value,
ActionType.COPY, ActionType.COPY,
) )
} }
fun createPasswordField(password: String): FieldItem { fun createPasswordField(label: String, password: String): FieldItem {
return FieldItem(ItemType.PASSWORD.label, password, ActionType.HIDE) return FieldItem(ItemType.PASSWORD, label, password, ActionType.HIDE)
} }
fun createUsernameField(username: String): FieldItem { fun createUsernameField(label: String, username: String): FieldItem {
return FieldItem(ItemType.USERNAME.label, username, ActionType.COPY) return FieldItem(ItemType.USERNAME, label, username, ActionType.COPY)
}
fun createFreeformField(label: String, content: String): FieldItem {
return FieldItem(ItemType.FREEFORM, label, content, ActionType.COPY)
} }
} }
} }

View file

@ -38,13 +38,13 @@ class FieldItemAdapter(
return fieldItemList.size return fieldItemList.size
} }
fun updateOTPCode(totp: Totp) { fun updateOTPCode(totp: Totp, labelFormat: String) {
var otpItemPosition = -1 var otpItemPosition = -1
fieldItemList = fieldItemList =
fieldItemList.mapIndexed { position, item -> fieldItemList.mapIndexed { position, item ->
if (item.key.startsWith(FieldItem.ItemType.OTP.type, true)) { if (item.type == FieldItem.ItemType.OTP) {
otpItemPosition = position otpItemPosition = position
return@mapIndexed FieldItem.createOtpField(totp) return@mapIndexed FieldItem.createOtpField(labelFormat, totp)
} }
return@mapIndexed item return@mapIndexed item
@ -58,8 +58,8 @@ class FieldItemAdapter(
fun bind(fieldItem: FieldItem, showPassword: Boolean, copyTextToClipboard: (String?) -> Unit) { fun bind(fieldItem: FieldItem, showPassword: Boolean, copyTextToClipboard: (String?) -> Unit) {
with(binding) { with(binding) {
itemText.hint = fieldItem.key itemText.hint = fieldItem.label
itemTextContainer.hint = fieldItem.key itemTextContainer.hint = fieldItem.label
itemText.setText(fieldItem.value) itemText.setText(fieldItem.value)
when (fieldItem.action) { when (fieldItem.action) {
@ -84,7 +84,7 @@ class FieldItemAdapter(
} else { } else {
null null
} }
if (fieldItem.key == FieldItem.ItemType.PASSWORD.type) { if (fieldItem.type == FieldItem.ItemType.PASSWORD) {
typeface = typeface =
ResourcesCompat.getFont( ResourcesCompat.getFont(
binding.root.context, binding.root.context,

View file

@ -136,8 +136,12 @@ class DecryptActivity : BasePGPActivity() {
intent.putExtra("FILE_PATH", relativeParentPath) intent.putExtra("FILE_PATH", relativeParentPath)
intent.putExtra("REPO_PATH", repoPath) intent.putExtra("REPO_PATH", repoPath)
intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name) intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name)
intent.putExtra(PasswordCreationActivity.EXTRA_USERNAME, passwordEntry?.username)
intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password) intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password)
intent.putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, passwordEntry?.extraContentString) intent.putExtra(
PasswordCreationActivity.EXTRA_EXTRA_CONTENT,
passwordEntry?.extraContentWithoutUsername,
)
intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true) intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true)
startActivity(intent) startActivity(intent)
finish() finish()
@ -274,27 +278,28 @@ class DecryptActivity : BasePGPActivity() {
private suspend fun createPasswordUI(entry: PasswordEntry) = private suspend fun createPasswordUI(entry: PasswordEntry) =
withContext(dispatcherProvider.main()) { withContext(dispatcherProvider.main()) {
val labelFormat = resources.getString(R.string.otp_label_format)
val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true) val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
invalidateOptionsMenu() invalidateOptionsMenu()
val items = arrayListOf<FieldItem>() val items = arrayListOf<FieldItem>()
if (!entry.password.isNullOrBlank()) { if (!entry.password.isNullOrBlank()) {
items.add(FieldItem.createPasswordField(entry.password!!)) items.add(FieldItem.createPasswordField(getString(R.string.password), entry.password!!))
if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) { if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) {
copyPasswordToClipboard(entry.password) copyPasswordToClipboard(entry.password)
} }
} }
if (entry.hasTotp()) { if (entry.hasTotp()) {
items.add(FieldItem.createOtpField(entry.totp.first())) items.add(FieldItem.createOtpField(labelFormat, entry.totp.first()))
} }
if (!entry.username.isNullOrBlank()) { if (!entry.username.isNullOrBlank()) {
items.add(FieldItem.createUsernameField(entry.username!!)) items.add(FieldItem.createUsernameField(getString(R.string.username), entry.username!!))
} }
entry.extraContent.forEach { (key, value) -> entry.extraContent.forEach { (key, value) ->
items.add(FieldItem(key, value, FieldItem.ActionType.COPY)) items.add(FieldItem.createFreeformField(key, value))
} }
val adapter = FieldItemAdapter(items, showPassword) { text -> copyTextToClipboard(text) } val adapter = FieldItemAdapter(items, showPassword) { text -> copyTextToClipboard(text) }
@ -302,7 +307,7 @@ class DecryptActivity : BasePGPActivity() {
binding.recyclerView.itemAnimator = null binding.recyclerView.itemAnimator = null
if (entry.hasTotp()) { if (entry.hasTotp()) {
lifecycleScope.launch { entry.totp.collect(adapter::updateOTPCode) } lifecycleScope.launch { entry.totp.collect { adapter.updateOTPCode(it, labelFormat) } }
} }
} }

View file

@ -78,6 +78,7 @@ class PasswordCreationActivity : BasePGPActivity() {
@Inject lateinit var passwordEntryFactory: PasswordEntry.Factory @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
private val suggestedName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) } private val suggestedName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) }
private val suggestedUsername by unsafeLazy { intent.getStringExtra(EXTRA_USERNAME) }
private val suggestedPass by unsafeLazy { intent.getStringExtra(EXTRA_PASSWORD) } private val suggestedPass by unsafeLazy { intent.getStringExtra(EXTRA_PASSWORD) }
private val suggestedExtra by unsafeLazy { intent.getStringExtra(EXTRA_EXTRA_CONTENT) } private val suggestedExtra by unsafeLazy { intent.getStringExtra(EXTRA_EXTRA_CONTENT) }
private val shouldGeneratePassword by unsafeLazy { private val shouldGeneratePassword by unsafeLazy {
@ -205,6 +206,16 @@ class PasswordCreationActivity : BasePGPActivity() {
} else { } else {
filename.requestFocus() filename.requestFocus()
} }
if (
AutofillPreferences.directoryStructure(this@PasswordCreationActivity) ==
DirectoryStructure.EncryptedUsername || suggestedUsername != null
) {
usernameInputLayout.visibility = View.VISIBLE
if (suggestedUsername != null) username.setText(suggestedUsername)
else if (suggestedName != null) username.requestFocus()
}
// Allow the user to quickly switch between storing the username as the filename or // Allow the user to quickly switch between storing the username as the filename or
// in the encrypted extras. This only makes sense if the directory structure is // in the encrypted extras. This only makes sense if the directory structure is
// FileBased. // FileBased.
@ -217,27 +228,19 @@ class PasswordCreationActivity : BasePGPActivity() {
visibility = View.VISIBLE visibility = View.VISIBLE
setOnClickListener { setOnClickListener {
if (isChecked) { if (isChecked) {
// User wants to enable username encryption, so we add it to the // User wants to enable username encryption, so we use the filename
// encrypted extras as the first line. // as username and insert it into the username input field.
val username = filename.text.toString() val login = filename.text.toString()
val extras = "username:$username\n${extraContent.text}"
filename.text?.clear() filename.text?.clear()
extraContent.setText(extras) username.setText(login)
usernameInputLayout.apply { visibility = View.VISIBLE }
} else { } else {
// User wants to disable username encryption, so we extract the // User wants to disable username encryption, so we take the username
// username from the encrypted extras and use it as the filename. // from the username text field and insert it into the filename input field.
val entry = val login = username.text.toString()
passwordEntryFactory.create("PASSWORD\n${extraContent.text}".encodeToByteArray()) username.text?.clear()
val username = entry.username filename.setText(login)
usernameInputLayout.apply { visibility = View.GONE }
// username should not be null here by the logic in
// updateViewState, but it could still happen due to
// input lag.
if (username != null) {
filename.setText(username)
extraContent.setText(entry.extraContentWithoutAuthData)
}
} }
} }
} }
@ -252,7 +255,7 @@ class PasswordCreationActivity : BasePGPActivity() {
password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
} }
} }
listOf(binding.filename, binding.extraContent).forEach { listOf(binding.filename, binding.username, binding.extraContent).forEach {
it.doAfterTextChanged { updateViewState() } it.doAfterTextChanged { updateViewState() }
} }
updateViewState() updateViewState()
@ -300,16 +303,16 @@ class PasswordCreationActivity : BasePGPActivity() {
private fun updateViewState() = private fun updateViewState() =
with(binding) { with(binding) {
// Use PasswordEntry to parse extras for username
val entry =
passwordEntryFactory.create("PLACEHOLDER\n${extraContent.text}".encodeToByteArray())
encryptUsername.apply { encryptUsername.apply {
if (visibility != View.VISIBLE) return@apply if (visibility != View.VISIBLE) return@apply
val hasUsernameInFileName = filename.text.toString().isNotBlank() val hasUsernameInFileName = filename.text.toString().isNotBlank()
val hasUsernameInExtras = !entry.username.isNullOrBlank() val usernameIsEncrypted = username.text.toString().isNotEmpty()
isEnabled = hasUsernameInFileName xor hasUsernameInExtras isEnabled = hasUsernameInFileName xor usernameIsEncrypted
isChecked = hasUsernameInExtras isChecked = usernameIsEncrypted
} }
// Use PasswordEntry to parse extras for OTP
val entry =
passwordEntryFactory.create("PLACEHOLDER\n${extraContent.text}".encodeToByteArray())
otpImportButton.isVisible = !entry.hasTotp() otpImportButton.isVisible = !entry.hasTotp()
} }
@ -318,6 +321,7 @@ class PasswordCreationActivity : BasePGPActivity() {
with(binding) { with(binding) {
val oldName = suggestedName val oldName = suggestedName
val editName = filename.text.toString().trim() val editName = filename.text.toString().trim()
var editUsername = username.text.toString()
val editPass = password.text.toString() val editPass = password.text.toString()
val editExtra = extraContent.text.toString() val editExtra = extraContent.text.toString()
@ -329,6 +333,10 @@ class PasswordCreationActivity : BasePGPActivity() {
return@with return@with
} }
if (!editUsername.isEmpty()) {
editUsername = "\nusername:$editUsername"
}
if (editPass.isEmpty() && editExtra.isEmpty()) { if (editPass.isEmpty() && editExtra.isEmpty()) {
snackbar(message = resources.getString(R.string.empty_toast_text)) snackbar(message = resources.getString(R.string.empty_toast_text))
return@with return@with
@ -340,7 +348,7 @@ class PasswordCreationActivity : BasePGPActivity() {
// pass enters the key ID into `.gpg-id`. // pass enters the key ID into `.gpg-id`.
val gpgIdentifiers = getPGPIdentifiers(directory.text.toString()) ?: return@with val gpgIdentifiers = getPGPIdentifiers(directory.text.toString()) ?: return@with
val content = "$editPass\n$editExtra" val content = "$editPass$editUsername\n$editExtra"
val path = val path =
when { when {
// If we allowed the user to edit the relative path, we have to consider it here // If we allowed the user to edit the relative path, we have to consider it here
@ -482,6 +490,7 @@ class PasswordCreationActivity : BasePGPActivity() {
const val RETURN_EXTRA_USERNAME = "USERNAME" const val RETURN_EXTRA_USERNAME = "USERNAME"
const val RETURN_EXTRA_PASSWORD = "PASSWORD" const val RETURN_EXTRA_PASSWORD = "PASSWORD"
const val EXTRA_FILE_NAME = "FILENAME" const val EXTRA_FILE_NAME = "FILENAME"
const val EXTRA_USERNAME = "USERNAME"
const val EXTRA_PASSWORD = "PASSWORD" const val EXTRA_PASSWORD = "PASSWORD"
const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT" const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT"
const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD" const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD"

View file

@ -55,6 +55,26 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:imeOptions="actionNext" android:imeOptions="actionNext"
android:inputType="textNoSuggestions" android:inputType="textNoSuggestions"
android:nextFocusForward="@id/username" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:visibility="gone"
tools:visibility="visible"
android:id="@+id/username_input_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:hint="@string/crypto_username_hint"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/name_input_layout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/username"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="textNoSuggestions"
android:nextFocusForward="@id/password" /> android:nextFocusForward="@id/password" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
@ -66,7 +86,7 @@
android:hint="@string/crypto_pass_label" android:hint="@string/crypto_pass_label"
app:endIconMode="password_toggle" app:endIconMode="password_toggle"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/name_input_layout"> app:layout_constraintTop_toBottomOf="@id/username_input_layout">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/password" android:id="@+id/password"

View file

@ -77,6 +77,7 @@
<!-- PGP Handler --> <!-- PGP Handler -->
<string name="crypto_name_hint">Name</string> <string name="crypto_name_hint">Name</string>
<string name="crypto_username_hint">Username</string>
<string name="crypto_pass_label">Password</string> <string name="crypto_pass_label">Password</string>
<string name="crypto_extra_label">Extra content</string> <string name="crypto_extra_label">Extra content</string>
<string name="crypto_encrypt_username_label">Encrypt username</string> <string name="crypto_encrypt_username_label">Encrypt username</string>
@ -88,6 +89,7 @@
<string name="action_search">Search</string> <string name="action_search">Search</string>
<string name="password">Password</string> <string name="password">Password</string>
<string name="username">Username</string> <string name="username">Username</string>
<string name="otp_label_format">OTP (expires in %ds)</string>
<string name="copy_label">Copy</string> <string name="copy_label">Copy</string>
<string name="edit_password">Edit password</string> <string name="edit_password">Edit password</string>
<string name="copy_password">Copy password</string> <string name="copy_password">Copy password</string>

View file

@ -80,6 +80,9 @@ constructor(
return otp.value.value return otp.value.value
} }
/** String representation of [extraContent] but with usernames stripped. */
public val extraContentWithoutUsername: String
/** /**
* String representation of [extraContent] but with authentication related data such as TOTP URIs * String representation of [extraContent] but with authentication related data such as TOTP URIs
* and usernames stripped. * and usernames stripped.
@ -91,6 +94,7 @@ constructor(
val (foundPassword, passContent) = findAndStripPassword(content.split("\n".toRegex())) val (foundPassword, passContent) = findAndStripPassword(content.split("\n".toRegex()))
password = foundPassword password = foundPassword
extraContentString = passContent.joinToString("\n") extraContentString = passContent.joinToString("\n")
extraContentWithoutUsername = generateExtraContentWithoutUsername()
extraContentWithoutAuthData = generateExtraContentWithoutAuthData() extraContentWithoutAuthData = generateExtraContentWithoutAuthData()
extraContent = generateExtraContentPairs() extraContent = generateExtraContentPairs()
username = findUsername() username = findUsername()
@ -121,7 +125,7 @@ constructor(
return Pair(passContent[0], passContent.minus(passContent[0])) return Pair(passContent[0], passContent.minus(passContent[0]))
} }
private fun generateExtraContentWithoutAuthData(): String { private fun generateExtraContentWithoutUsername(): String {
var foundUsername = false var foundUsername = false
return extraContentString return extraContentString
.lineSequence() .lineSequence()
@ -132,6 +136,19 @@ constructor(
foundUsername = true foundUsername = true
false false
} }
else -> {
true
}
}
}
.joinToString(separator = "\n")
}
private fun generateExtraContentWithoutAuthData(): String {
return generateExtraContentWithoutUsername()
.lineSequence()
.filter { line ->
return@filter when {
TotpFinder.TOTP_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) } -> { TotpFinder.TOTP_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) } -> {
false false
} }