Parse extra content into key value pairs (#1321)

* ui: add skeleton recyclerview to parse extra content

Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>

* ui: add recyclerview and update PasswordEntry to create map of key-value pairs

Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>

* password-entry: When key-value pair is not correctly formed, display it as Extra Content

Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>

* Fix formatting

Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>

* bug: update otp code on main thread

Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>

* Add complete string if key-value pair cannot be formed

Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>

* test: add a few tests for key-value parsing logic

Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>

* prefs: remove SHOW_EXTRA_CONTENT from shared preferences

Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>

* Update CHANGELOG.md

* Cleanup and refactor

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>

* PasswordEntryTest: silence nullability warning

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>

* PasswordEntry: simplify constructor

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>

* PasswordEntry: annotate test-enablement visibility

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>

* Reintroduce the catch-all field

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>

* update parsing logic

Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>

* add one more test case

Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>

* Add missing newlines

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>

* Remove unnecessary scrollview

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>

* rv: do not return if hasExtraContent is false

Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>

* Don't anchor RV to bottom

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>

Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
Aditya Wasan 2021-02-19 13:57:57 +05:30 committed by GitHub
parent 92ece7dbb5
commit d17ff0d925
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 357 additions and 239 deletions

View file

@ -36,6 +36,9 @@
<option name="IF_RPAREN_ON_NEW_LINE" value="false" /> <option name="IF_RPAREN_ON_NEW_LINE" value="false" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" /> <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings> </JetCodeStyleSettings>
<editorconfig>
<option name="ENABLED" value="false" />
</editorconfig>
<codeStyleSettings language="JAVA"> <codeStyleSettings language="JAVA">
<option name="METHOD_ANNOTATION_WRAP" value="0" /> <option name="METHOD_ANNOTATION_WRAP" value="0" />
<option name="FIELD_ANNOTATION_WRAP" value="0" /> <option name="FIELD_ANNOTATION_WRAP" value="0" />

View file

@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file.
- Suggest users to re-clone repository when it is deemed to be broken - Suggest users to re-clone repository when it is deemed to be broken
- Allow doing a merge instead of a rebase when pulling or syncing - Allow doing a merge instead of a rebase when pulling or syncing
- Add support for manually providing TOTP parameters - Add support for manually providing TOTP parameters
- Parse extra content as individual fields
### Fixed ### Fixed

View file

@ -0,0 +1,27 @@
package dev.msfjarvis.aps.data.password
class FieldItem(val key: String, val value: String, val action: ActionType) {
enum class ActionType {
COPY, HIDE
}
enum class ItemType(val type: String) {
USERNAME("Username"), PASSWORD("Password"), OTP("OTP")
}
companion object {
// Extra helper methods
fun createOtpField(otp: String): FieldItem {
return FieldItem(ItemType.OTP.type, otp, ActionType.COPY)
}
fun createPasswordField(password: String): FieldItem {
return FieldItem(ItemType.PASSWORD.type, password, ActionType.HIDE)
}
fun createUsernameField(username: String): FieldItem {
return FieldItem(ItemType.USERNAME.type, username, ActionType.COPY)
}
}
}

View file

@ -4,12 +4,12 @@
*/ */
package dev.msfjarvis.aps.data.password package dev.msfjarvis.aps.data.password
import androidx.annotation.VisibleForTesting
import com.github.michaelbull.result.get import com.github.michaelbull.result.get
import dev.msfjarvis.aps.util.totp.Otp import dev.msfjarvis.aps.util.totp.Otp
import dev.msfjarvis.aps.util.totp.TotpFinder import dev.msfjarvis.aps.util.totp.TotpFinder
import dev.msfjarvis.aps.util.totp.UriTotpFinder import dev.msfjarvis.aps.util.totp.UriTotpFinder
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.UnsupportedEncodingException
import java.util.Date import java.util.Date
/** /**
@ -20,20 +20,28 @@ class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTot
val password: String val password: String
val username: String? val username: String?
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
val digits: String val digits: String
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
val totpSecret: String? val totpSecret: String?
val totpPeriod: Long val totpPeriod: Long
val totpAlgorithm: String
var extraContent: String
private set
@Throws(UnsupportedEncodingException::class) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
constructor(os: ByteArrayOutputStream) : this(os.toString("UTF-8"), UriTotpFinder()) val totpAlgorithm: String
val extraContent: String
val extraContentWithoutAuthData: String
val extraContentMap: Map<String, String>
constructor(os: ByteArrayOutputStream) : this(os.toString(Charsets.UTF_8.name()), UriTotpFinder())
init { init {
val (foundPassword, passContent) = findAndStripPassword(content.split("\n".toRegex())) val (foundPassword, passContent) = findAndStripPassword(content.split("\n".toRegex()))
password = foundPassword password = foundPassword
extraContent = passContent.joinToString("\n") extraContent = passContent.joinToString("\n")
extraContentWithoutAuthData = generateExtraContentWithoutAuthData()
extraContentMap = generateExtraContentPairs()
username = findUsername() username = findUsername()
digits = findOtpDigits(content) digits = findOtpDigits(content)
totpSecret = findTotpSecret(content) totpSecret = findTotpSecret(content)
@ -45,6 +53,10 @@ class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTot
return extraContent.isNotEmpty() return extraContent.isNotEmpty()
} }
fun hasExtraContentWithoutAuthData(): Boolean {
return extraContentWithoutAuthData.isNotEmpty()
}
fun hasTotp(): Boolean { fun hasTotp(): Boolean {
return totpSecret != null return totpSecret != null
} }
@ -59,9 +71,11 @@ class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTot
return Otp.calculateCode(totpSecret, Date().time / (1000 * totpPeriod), totpAlgorithm, digits).get() return Otp.calculateCode(totpSecret, Date().time / (1000 * totpPeriod), totpAlgorithm, digits).get()
} }
val extraContentWithoutAuthData by lazy(LazyThreadSafetyMode.NONE) { private fun generateExtraContentWithoutAuthData(): String {
var foundUsername = false var foundUsername = false
extraContent.splitToSequence("\n").filter { line -> return extraContent
.lineSequence()
.filter { line ->
return@filter when { return@filter when {
USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) } && !foundUsername -> { USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) } && !foundUsername -> {
foundUsername = true foundUsername = true
@ -78,6 +92,44 @@ class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTot
}.joinToString(separator = "\n") }.joinToString(separator = "\n")
} }
private fun generateExtraContentPairs(): Map<String, String> {
fun MutableMap<String, String>.putOrAppend(key: String, value: String) {
if (value.isEmpty()) return
val existing = this[key]
this[key] = if (existing == null) {
value
} else {
"$existing\n$value"
}
}
val items = mutableMapOf<String, String>()
// Take extraContentWithoutAuthData and onEach line perform the following tasks
extraContentWithoutAuthData.lines().forEach { line ->
// Split the line on ':' and save all the parts into an array
// "ABC : DEF:GHI" --> ["ABC", "DEF", "GHI"]
val splitArray = line.split(":")
// Take the first element of the array. This will be the key for the key-value pair.
// ["ABC ", " DEF", "GHI"] -> key = "ABC"
val key = splitArray.first().trimEnd()
// Remove the first element from the array and join the rest of the string again with ':' as separator.
// ["ABC ", " DEF", "GHI"] -> value = "DEF:GHI"
val value = splitArray.drop(1).joinToString(":").trimStart()
if (key.isNotEmpty() && value.isNotEmpty()) {
// If both key and value are not empty, we can form a pair with this so add it to the map.
// key = "ABC", value = "DEF:GHI"
items[key] = value
} else {
// If either key or value is empty, we were not able to form proper key-value pair.
// So append the original line into an "EXTRA CONTENT" map entry
items.putOrAppend(EXTRA_CONTENT, line)
}
}
return items
}
private fun findUsername(): String? { private fun findUsername(): String? {
extraContent.splitToSequence("\n").forEach { line -> extraContent.splitToSequence("\n").forEach { line ->
for (prefix in USERNAME_FIELDS) { for (prefix in USERNAME_FIELDS) {
@ -118,6 +170,9 @@ class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTot
companion object { companion object {
private const val EXTRA_CONTENT = "Extra Content"
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
val USERNAME_FIELDS = arrayOf( val USERNAME_FIELDS = arrayOf(
"login:", "login:",
"username:", "username:",
@ -127,9 +182,10 @@ class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTot
"name:", "name:",
"handle:", "handle:",
"id:", "id:",
"identity:" "identity:",
) )
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
val PASSWORD_FIELDS = arrayOf( val PASSWORD_FIELDS = arrayOf(
"password:", "password:",
"secret:", "secret:",

View file

@ -0,0 +1,85 @@
package dev.msfjarvis.aps.ui.adapters
import android.text.method.PasswordTransformationMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.textfield.TextInputLayout
import dev.msfjarvis.aps.R
import dev.msfjarvis.aps.data.password.FieldItem
import dev.msfjarvis.aps.databinding.ItemFieldBinding
class FieldItemAdapter(
private var fieldItemList: List<FieldItem>,
private val showPassword: Boolean,
private val copyTextToClipBoard: (text: String?) -> Unit,
) : RecyclerView.Adapter<FieldItemAdapter.FieldItemViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldItemViewHolder {
val binding = ItemFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return FieldItemViewHolder(binding.root, binding)
}
override fun onBindViewHolder(holder: FieldItemViewHolder, position: Int) {
holder.bind(fieldItemList[position], showPassword, copyTextToClipBoard)
}
override fun getItemCount(): Int {
return fieldItemList.size
}
fun updateOTPCode(code: String) {
var otpItemPosition = -1;
fieldItemList = fieldItemList.mapIndexed { position, item ->
if (item.key.equals(FieldItem.ItemType.OTP.type, true)) {
otpItemPosition = position
return@mapIndexed FieldItem.createOtpField(code)
}
return@mapIndexed item
}
notifyItemChanged(otpItemPosition)
}
fun updateItems(itemList: List<FieldItem>) {
fieldItemList = itemList
notifyDataSetChanged()
}
class FieldItemViewHolder(itemView: View, val binding: ItemFieldBinding) :
RecyclerView.ViewHolder(itemView) {
fun bind(fieldItem: FieldItem, showPassword: Boolean, copyTextToClipBoard: (String?) -> Unit) {
with(binding) {
itemText.hint = fieldItem.key
itemTextContainer.hint = fieldItem.key
itemText.setText(fieldItem.value)
when (fieldItem.action) {
FieldItem.ActionType.COPY -> {
itemTextContainer.apply {
endIconDrawable = ContextCompat.getDrawable(itemView.context, R.drawable.ic_content_copy)
endIconMode = TextInputLayout.END_ICON_CUSTOM
setEndIconOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
}
}
FieldItem.ActionType.HIDE -> {
itemTextContainer.apply {
endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
setOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
}
itemText.apply {
if (!showPassword) {
transformationMethod = PasswordTransformationMethod.getInstance()
}
setOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
}
}
}
}
}
}
}

View file

@ -6,9 +6,7 @@
package dev.msfjarvis.aps.ui.crypto package dev.msfjarvis.aps.ui.crypto
import android.content.Intent import android.content.Intent
import android.graphics.Typeface
import android.os.Bundle import android.os.Bundle
import android.text.method.PasswordTransformationMethod
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
@ -19,8 +17,10 @@ import com.github.ajalt.timberkt.e
import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.runCatching import com.github.michaelbull.result.runCatching
import dev.msfjarvis.aps.R import dev.msfjarvis.aps.R
import dev.msfjarvis.aps.data.password.FieldItem
import dev.msfjarvis.aps.data.password.PasswordEntry import dev.msfjarvis.aps.data.password.PasswordEntry
import dev.msfjarvis.aps.databinding.DecryptLayoutBinding import dev.msfjarvis.aps.databinding.DecryptLayoutBinding
import dev.msfjarvis.aps.ui.adapters.FieldItemAdapter
import dev.msfjarvis.aps.util.extensions.viewBinding import dev.msfjarvis.aps.util.extensions.viewBinding
import dev.msfjarvis.aps.util.settings.PreferenceKeys import dev.msfjarvis.aps.util.settings.PreferenceKeys
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
@ -172,80 +172,56 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
startAutoDismissTimer() startAutoDismissTimer()
runCatching { runCatching {
val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true) val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
val showExtraContent = settings.getBoolean(PreferenceKeys.SHOW_EXTRA_CONTENT, true)
val monoTypeface = Typeface.createFromAsset(assets, "fonts/sourcecodepro.ttf")
val entry = PasswordEntry(outputStream) val entry = PasswordEntry(outputStream)
val items = arrayListOf<FieldItem>()
passwordEntry = entry val adapter = FieldItemAdapter(emptyList(), showPassword) { text ->
invalidateOptionsMenu() copyTextToClipboard(text)
with(binding) {
if (entry.password.isEmpty()) {
passwordTextContainer.visibility = View.GONE
} else {
passwordTextContainer.visibility = View.VISIBLE
passwordText.typeface = monoTypeface
passwordText.setText(entry.password)
if (!showPassword) {
passwordText.transformationMethod = PasswordTransformationMethod.getInstance()
}
passwordTextContainer.setOnClickListener { copyPasswordToClipboard(entry.password) }
passwordText.setOnClickListener { copyPasswordToClipboard(entry.password) }
}
if (entry.hasExtraContent()) {
if (entry.extraContentWithoutAuthData.isNotEmpty()) {
extraContentContainer.visibility = View.VISIBLE
extraContent.typeface = monoTypeface
extraContent.setText(entry.extraContentWithoutAuthData)
if (!showExtraContent) {
extraContent.transformationMethod = PasswordTransformationMethod.getInstance()
}
extraContentContainer.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutAuthData) }
extraContent.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutAuthData) }
}
if (entry.hasUsername()) {
usernameText.typeface = monoTypeface
usernameText.setText(entry.username)
usernameTextContainer.setEndIconOnClickListener { copyTextToClipboard(entry.username) }
usernameTextContainer.visibility = View.VISIBLE
} else {
usernameTextContainer.visibility = View.GONE
}
if (entry.hasTotp()) {
otpTextContainer.visibility = View.VISIBLE
otpTextContainer.setEndIconOnClickListener {
copyTextToClipboard(
otpText.text.toString(),
snackbarTextRes = R.string.clipboard_otp_copied_text
)
}
launch(Dispatchers.IO) {
// Calculate the actual remaining time for the first pass
// then return to the standard 30 second affair.
val remainingTime = entry.totpPeriod - (System.currentTimeMillis() % entry.totpPeriod)
withContext(Dispatchers.Main) {
otpText.setText(entry.calculateTotpCode()
?: "Error")
}
delay(remainingTime.seconds)
repeat(Int.MAX_VALUE) {
val code = entry.calculateTotpCode() ?: "Error"
withContext(Dispatchers.Main) {
otpText.setText(code)
}
delay(30.seconds)
}
}
}
}
} }
if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) { if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) {
copyPasswordToClipboard(entry.password) copyPasswordToClipboard(entry.password)
} }
passwordEntry = entry
invalidateOptionsMenu()
if (entry.password.isNotEmpty()) {
items.add(FieldItem.createPasswordField(entry.password))
}
if (entry.hasTotp()) {
launch(Dispatchers.IO) {
// Calculate the actual remaining time for the first pass
// then return to the standard 30 second affair.
val remainingTime =
entry.totpPeriod - (System.currentTimeMillis() % entry.totpPeriod)
withContext(Dispatchers.Main) {
val code = entry.calculateTotpCode() ?: "Error"
items.add(FieldItem.createOtpField(code))
}
delay(remainingTime.seconds)
repeat(Int.MAX_VALUE) {
val code = entry.calculateTotpCode() ?: "Error"
withContext(Dispatchers.Main) {
adapter.updateOTPCode(code)
}
delay(30.seconds)
}
}
}
if (!entry.username.isNullOrEmpty()) {
items.add(FieldItem.createUsernameField(entry.username))
}
if (entry.hasExtraContentWithoutAuthData()) {
entry.extraContentMap.forEach { (key, value) ->
items.add(FieldItem(key, value, FieldItem.ActionType.COPY))
}
}
binding.recyclerView.adapter = adapter
adapter.updateItems(items)
}.onFailure { e -> }.onFailure { e ->
e(e) e(e)
} }

View file

@ -103,11 +103,6 @@ class PasswordSettings(private val activity: FragmentActivity) : SettingsProvide
summaryRes = R.string.show_password_pref_summary summaryRes = R.string.show_password_pref_summary
defaultValue = true defaultValue = true
} }
checkBox(PreferenceKeys.SHOW_EXTRA_CONTENT) {
titleRes = R.string.show_extra_content_pref_title
summaryRes = R.string.show_extra_content_pref_summary
defaultValue = true
}
checkBox(PreferenceKeys.COPY_ON_DECRYPT) { checkBox(PreferenceKeys.COPY_ON_DECRYPT) {
titleRes = R.string.pref_copy_title titleRes = R.string.pref_copy_title
summaryRes = R.string.pref_copy_summary summaryRes = R.string.pref_copy_summary

View file

@ -62,7 +62,6 @@ object PreferenceKeys {
const val REPOSITORY_INITIALIZED = "repository_initialized" const val REPOSITORY_INITIALIZED = "repository_initialized"
const val REPO_CHANGED = "repo_changed" const val REPO_CHANGED = "repo_changed"
const val SEARCH_ON_START = "search_on_start" const val SEARCH_ON_START = "search_on_start"
const val SHOW_EXTRA_CONTENT = "show_extra_content"
@Deprecated( @Deprecated(
message = "Use SHOW_HIDDEN_CONTENTS instead", message = "Use SHOW_HIDDEN_CONTENTS instead",

View file

@ -3,19 +3,14 @@
~ SPDX-License-Identifier: GPL-3.0-only ~ SPDX-License-Identifier: GPL-3.0-only
--> -->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".ui.crypto.DecryptActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:padding="16dp"> android:padding="16dp"
tools:context=".ui.crypto.DecryptActivity">
<androidx.appcompat.widget.AppCompatTextView <androidx.appcompat.widget.AppCompatTextView
android:id="@+id/password_category" android:id="@+id/password_category"
@ -67,92 +62,14 @@
app:layout_constraintTop_toBottomOf="@id/password_last_changed" app:layout_constraintTop_toBottomOf="@id/password_last_changed"
tools:ignore="ContentDescription" /> tools:ignore="ContentDescription" />
<com.google.android.material.textfield.TextInputLayout <androidx.recyclerview.widget.RecyclerView
android:id="@+id/password_text_container" android:id="@+id/recycler_view"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="16dp" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
android:hint="@string/password"
android:visibility="gone"
app:endIconMode="password_toggle"
app:layout_constraintTop_toBottomOf="@id/divider" app:layout_constraintTop_toBottomOf="@id/divider"
tools:visibility="visible"> app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:listitem="@layout/item_field" />
<com.google.android.material.textfield.TextInputEditText </androidx.constraintlayout.widget.ConstraintLayout>
android:id="@+id/password_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:editable="false"
android:fontFamily="@font/sourcecodepro"
android:textIsSelectable="true"
tools:text="p@55w0rd!" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/otp_text_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/otp"
android:visibility="gone"
app:endIconDrawable="@drawable/ic_content_copy"
app:endIconMode="custom"
app:layout_constraintTop_toBottomOf="@id/password_text_container"
tools:visibility="visible">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/otp_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:editable="false"
android:fontFamily="@font/sourcecodepro"
android:textIsSelectable="true"
tools:text="123456" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/username_text_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/username"
android:visibility="gone"
app:endIconDrawable="@drawable/ic_content_copy"
app:endIconMode="custom"
app:layout_constraintTop_toBottomOf="@id/otp_text_container"
tools:visibility="visible">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/username_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:editable="false"
android:textIsSelectable="true"
tools:text="totally_real_user@example.com" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/extra_content_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/extra_content"
android:visibility="gone"
app:endIconMode="password_toggle"
app:layout_constraintTop_toBottomOf="@id/username_text_container"
tools:visibility="visible">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/extra_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:editable="false"
android:textIsSelectable="true"
tools:text="lots of extra content that will surely fill this \n up well" />
</com.google.android.material.textfield.TextInputLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/item_text_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
tools:visibility="visible"
tools:hint="@string/password">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/item_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:editable="false"
android:fontFamily="@font/sourcecodepro"
android:textIsSelectable="true"
tools:text="p@55w0rd!" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View file

@ -50,6 +50,38 @@ class PasswordEntryTest {
assertEquals("", makeEntry("").extraContent) assertEquals("", makeEntry("").extraContent)
} }
@Test fun parseExtraContentWithoutAuth() {
var entry = makeEntry("username: abc\npassword: abc\ntest: abcdef")
assertEquals(1, entry.extraContentMap.size)
assertTrue(entry.extraContentMap.containsKey("test"))
assertEquals("abcdef", entry.extraContentMap["test"])
entry = makeEntry("username: abc\npassword: abc\ntest: :abcdef:")
assertEquals(1, entry.extraContentMap.size)
assertTrue(entry.extraContentMap.containsKey("test"))
assertEquals(":abcdef:", entry.extraContentMap["test"])
entry = makeEntry("username: abc\npassword: abc\ntest : ::abc:def::")
assertEquals(1, entry.extraContentMap.size)
assertTrue(entry.extraContentMap.containsKey("test"))
assertEquals("::abc:def::", entry.extraContentMap["test"])
entry = makeEntry("username: abc\npassword: abc\ntest: abcdef\ntest2: ghijkl")
assertEquals(2, entry.extraContentMap.size)
assertTrue(entry.extraContentMap.containsKey("test2"))
assertEquals("ghijkl", entry.extraContentMap["test2"])
entry = makeEntry("username: abc\npassword: abc\ntest: abcdef\n: ghijkl\n mnopqr:")
assertEquals(2, entry.extraContentMap.size)
assertTrue(entry.extraContentMap.containsKey("Extra Content"))
assertEquals(": ghijkl\n mnopqr:", entry.extraContentMap["Extra Content"])
entry = makeEntry("username: abc\npassword: abc\n:\n\n")
assertEquals(1, entry.extraContentMap.size)
assertTrue(entry.extraContentMap.containsKey("Extra Content"))
assertEquals(":", entry.extraContentMap["Extra Content"])
}
@Test fun testGetUsername() { @Test fun testGetUsername() {
for (field in PasswordEntry.USERNAME_FIELDS) { for (field in PasswordEntry.USERNAME_FIELDS) {
assertEquals("username", makeEntry("\n$field username").username) assertEquals("username", makeEntry("\n$field username").username)
@ -133,7 +165,7 @@ class PasswordEntryTest {
// This implementation is hardcoded for the URI above. // This implementation is hardcoded for the URI above.
val testFinder = object : TotpFinder { val testFinder = object : TotpFinder {
override fun findSecret(content: String): String? { override fun findSecret(content: String): String {
return "HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ" return "HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ"
} }