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:
parent
92ece7dbb5
commit
d17ff0d925
11 changed files with 357 additions and 239 deletions
|
@ -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" />
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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:",
|
||||||
|
|
|
@ -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()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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>
|
|
||||||
|
|
27
app/src/main/res/layout/item_field.xml
Normal file
27
app/src/main/res/layout/item_field.xml
Normal 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>
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue