diff options
author | Fabian Henneke <FabianHenneke@users.noreply.github.com> | 2020-03-25 18:13:04 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-03-25 18:13:04 +0100 |
commit | fde16c60f4ce5d57a0c7d5a0186dcd532a23f0f0 (patch) | |
tree | 8a72bcc077590707c47017548cc8adc3eae6e0d1 /app/src/main/java/com | |
parent | 973e023dda3caf17fe024b9ba7f1872c5527e654 (diff) |
Make preferred directory structure for Autofill configurable (#660)
Some users keep their password files in a directory structure such as:
/example.org/john@doe.org.gpg
while others prefer the style:
/example.org/john@doe.org/password.gpg
This commit adds a setting that allows to switch between the two. All Autofill
operations, such as search, match, generate and save, respect this setting.
Note: The first style seems to be the most widely used and is therefore kept as
the default. The second style is mentioned on the official Pass website at:
https://www.passwordstore.org/#organization
Diffstat (limited to 'app/src/main/java/com')
7 files changed, 193 insertions, 65 deletions
diff --git a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt index 895338e9..06ee24e5 100644 --- a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt +++ b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt @@ -62,8 +62,9 @@ class UserPreference : AppCompatActivity() { private lateinit var prefsFragment: PrefsFragment class PrefsFragment : PreferenceFragmentCompat() { - private var autofillDependencies = listOf<Preference?>() private var autoFillEnablePreference: SwitchPreferenceCompat? = null + private lateinit var autofillDependencies: List<Preference> + private lateinit var oreoAutofillDependencies: List<Preference> private lateinit var callingActivity: UserPreference override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { @@ -96,16 +97,21 @@ class UserPreference : AppCompatActivity() { // Autofill preferences autoFillEnablePreference = findPreference("autofill_enable") - val autoFillAppsPreference = findPreference<Preference>("autofill_apps") - val autoFillDefaultPreference = findPreference<CheckBoxPreference>("autofill_default") - val autoFillAlwaysShowDialogPreference = findPreference<CheckBoxPreference>("autofill_always") - val autoFillShowFullNamePreference = findPreference<CheckBoxPreference>("autofill_full_path") + val autoFillAppsPreference = findPreference<Preference>("autofill_apps")!! + val autoFillDefaultPreference = findPreference<CheckBoxPreference>("autofill_default")!! + val autoFillAlwaysShowDialogPreference = + findPreference<CheckBoxPreference>("autofill_always")!! + val autoFillShowFullNamePreference = + findPreference<CheckBoxPreference>("autofill_full_path")!! autofillDependencies = listOf( autoFillAppsPreference, autoFillDefaultPreference, autoFillAlwaysShowDialogPreference, autoFillShowFullNamePreference ) + val oreoAutofillDirectoryStructurePreference = + findPreference<ListPreference>("oreo_autofill_directory_structure")!! + oreoAutofillDependencies = listOf(oreoAutofillDirectoryStructurePreference) // Misc preferences val appVersionPreference = findPreference<Preference>("app_version") @@ -236,7 +242,7 @@ class UserPreference : AppCompatActivity() { selectExternalGitRepositoryPreference?.onPreferenceChangeListener = resetRepo externalGitRepositoryPreference?.onPreferenceChangeListener = resetRepo - autoFillAppsPreference?.onPreferenceClickListener = ClickListener { + autoFillAppsPreference.onPreferenceClickListener = ClickListener { val intent = Intent(callingActivity, AutofillPreferenceActivity::class.java) startActivity(intent) true @@ -356,10 +362,14 @@ class UserPreference : AppCompatActivity() { private fun updateAutofillSettings() { val isAccessibilityServiceEnabled = callingActivity.isAccessibilityServiceEnabled + val isAutofillServiceEnabled = callingActivity.isAutofillServiceEnabled autoFillEnablePreference?.isChecked = - isAccessibilityServiceEnabled || callingActivity.isAutofillServiceEnabled + isAccessibilityServiceEnabled || isAutofillServiceEnabled autofillDependencies.forEach { - it?.isVisible = isAccessibilityServiceEnabled + it.isVisible = isAccessibilityServiceEnabled + } + oreoAutofillDependencies.forEach { + it.isVisible = isAutofillServiceEnabled } } @@ -409,16 +419,7 @@ class UserPreference : AppCompatActivity() { startActivity(intent) } setNegativeButton(R.string.dialog_cancel, null) - setOnDismissListener { - val isEnabled = - if (enableOreoAutofill && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - callingActivity.isAutofillServiceEnabled - } else { - callingActivity.isAccessibilityServiceEnabled - } - autoFillEnablePreference?.isChecked = isEnabled - autofillDependencies.forEach { it?.isVisible = isEnabled } - } + setOnDismissListener { updateAutofillSettings() } show() } } diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt index 4b7b47e2..e3d48302 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt @@ -73,9 +73,14 @@ val AssistStructure.ViewNode.webOrigin: String? data class Credentials(val username: String?, val password: String) { companion object { - fun fromStoreEntry(file: File, entry: PasswordEntry): Credentials { - return if (entry.hasUsername()) Credentials(entry.username, entry.password) - else Credentials(file.nameWithoutExtension, entry.password) + fun fromStoreEntry( + file: File, + entry: PasswordEntry, + directoryStructure: DirectoryStructure + ): Credentials { + // Always give priority to a username stored in the encrypted extras + val username = entry.username ?: directoryStructure.getUsernameFor(file) + return Credentials(username, entry.password) } } } @@ -95,7 +100,7 @@ private fun makeRemoteView( fun makeFillMatchRemoteView(context: Context, file: File, formOrigin: FormOrigin): RemoteViews { val title = formOrigin.getPrettyIdentifier(context, untrusted = false) - val summary = file.nameWithoutExtension + val summary = AutofillPreferences.directoryStructure(context).getUsernameFor(file) val iconRes = R.drawable.ic_person_black_24dp return makeRemoteView(context, title, summary, iconRes) } diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt new file mode 100644 index 00000000..c1ce51e9 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt @@ -0,0 +1,59 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.autofill.oreo + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.preference.PreferenceManager +import java.io.File +import java.nio.file.Paths + +private val Context.defaultSharedPreferences: SharedPreferences + get() = PreferenceManager.getDefaultSharedPreferences(this) + +enum class DirectoryStructure(val value: String) { + FileBased("file"), + DirectoryBased("directory"); + + fun getUsernameFor(file: File) = when (this) { + FileBased -> file.nameWithoutExtension + DirectoryBased -> file.parentFile?.name ?: file.nameWithoutExtension + } + + fun getIdentifierFor(file: File) = when (this) { + FileBased -> file.parentFile?.name + DirectoryBased -> file.parentFile?.parentFile?.name + } + + @RequiresApi(Build.VERSION_CODES.O) + fun getSaveFolderName(sanitizedIdentifier: String, username: String?) = when (this) { + FileBased -> sanitizedIdentifier + DirectoryBased -> Paths.get(sanitizedIdentifier, username ?: "username").toString() + } + + fun getSaveFileName(username: String?) = when (this) { + FileBased -> username + DirectoryBased -> "password" + } + + companion object { + const val PREFERENCE = "oreo_autofill_directory_structure" + private val DEFAULT = FileBased + + private val reverseMap = values().associateBy { it.value } + fun fromValue(value: String?) = if (value != null) reverseMap[value] ?: DEFAULT else DEFAULT + } +} + +object AutofillPreferences { + + fun directoryStructure(context: Context): DirectoryStructure { + val value = + context.defaultSharedPreferences.getString(DirectoryStructure.PREFERENCE, null) + return DirectoryStructure.fromValue(value) + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt index 2f3824f9..2c44ee42 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt @@ -18,7 +18,9 @@ import com.github.ajalt.timberkt.d import com.github.ajalt.timberkt.e import com.zeapo.pwdstore.PasswordEntry import com.zeapo.pwdstore.autofill.oreo.AutofillAction +import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences import com.zeapo.pwdstore.autofill.oreo.Credentials +import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure import com.zeapo.pwdstore.autofill.oreo.FillableForm import java.io.ByteArrayOutputStream import java.io.File @@ -76,6 +78,7 @@ class AutofillDecryptActivity : Activity(), CoroutineScope { } private var continueAfterUserInteraction: Continuation<Intent>? = null + private lateinit var directoryStructure: DirectoryStructure override val coroutineContext get() = Dispatchers.IO + SupervisorJob() @@ -94,6 +97,7 @@ class AutofillDecryptActivity : Activity(), CoroutineScope { } val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!! val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match + directoryStructure = AutofillPreferences.directoryStructure(this) d { action.toString() } launch { val credentials = decryptUsernameAndPassword(File(filePath)) @@ -176,7 +180,7 @@ class AutofillDecryptActivity : Activity(), CoroutineScope { val entry = withContext(Dispatchers.IO) { PasswordEntry(decryptedOutput) } - Credentials.fromStoreEntry(file, entry) + Credentials.fromStoreEntry(file, entry, directoryStructure) } catch (e: UnsupportedEncodingException) { e(e) { "Failed to parse password entry" } null diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt index 8c77fff8..e4517197 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt @@ -23,10 +23,13 @@ import com.afollestad.recyclical.withItem import com.github.ajalt.timberkt.e import com.zeapo.pwdstore.R import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher +import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences +import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure import com.zeapo.pwdstore.autofill.oreo.FormOrigin import com.zeapo.pwdstore.utils.PasswordItem import com.zeapo.pwdstore.utils.PasswordRepository import java.io.File +import java.nio.file.Paths import java.util.Locale import kotlinx.android.synthetic.main.activity_oreo_autofill_filter.* @@ -69,6 +72,8 @@ class AutofillFilterView : AppCompatActivity() { get() = PasswordRepository.PasswordSortOrder.getSortOrder(preferences) private lateinit var formOrigin: FormOrigin + private lateinit var repositoryRoot: File + private lateinit var directoryStructure: DirectoryStructure override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -98,6 +103,8 @@ class AutofillFilterView : AppCompatActivity() { return } } + repositoryRoot = PasswordRepository.getRepositoryDirectory(this) + directoryStructure = AutofillPreferences.directoryStructure(this) supportActionBar?.hide() bindUI() @@ -110,9 +117,19 @@ class AutofillFilterView : AppCompatActivity() { withDataSource(dataSource) withItem<PasswordItem, PasswordViewHolder>(R.layout.oreo_autofill_filter_row) { onBind(::PasswordViewHolder) { _, item -> - title.text = item.fullPathToParent - // drop the .gpg extension - subtitle.text = item.name.dropLast(4) + when (directoryStructure) { + DirectoryStructure.FileBased -> { + title.text = item.file.relativeTo(item.rootDir).parent + subtitle.text = item.file.nameWithoutExtension + } + DirectoryStructure.DirectoryBased -> { + title.text = + item.file.relativeTo(item.rootDir).parentFile?.parent ?: "/INVALID" + subtitle.text = + Paths.get(item.file.parentFile.name, item.file.nameWithoutExtension) + .toString() + } + } } onClick { decryptAndFill(item) } } @@ -156,40 +173,41 @@ class AutofillFilterView : AppCompatActivity() { } } + private fun File.matches(filter: String, strict: Boolean): Boolean { + return if (strict) { + val toMatch = directoryStructure.getIdentifierFor(this) ?: return false + // In strict mode, we match + // * the search term exactly, + // * subdomains of the search term, + // * or the search term plus an arbitrary protocol. + toMatch == filter || toMatch.endsWith(".$filter") || toMatch.endsWith("://$filter") + } else { + val toMatch = + "${relativeTo(repositoryRoot).path}/$nameWithoutExtension".toLowerCase(Locale.getDefault()) + toMatch.contains(filter.toLowerCase(Locale.getDefault())) + } + } + private fun recursiveFilter(filter: String, dir: File? = null, strict: Boolean = true) { - val root = PasswordRepository.getRepositoryDirectory(this) // on the root the pathStack is empty val passwordItems = if (dir == null) { - PasswordRepository.getPasswords( - PasswordRepository.getRepositoryDirectory(this), - sortOrder - ) + PasswordRepository.getPasswords(repositoryRoot, sortOrder) } else { - PasswordRepository.getPasswords( - dir, - PasswordRepository.getRepositoryDirectory(this), - sortOrder - ) + PasswordRepository.getPasswords(dir, repositoryRoot, sortOrder) } for (item in passwordItems) { if (item.type == PasswordItem.TYPE_CATEGORY) { recursiveFilter(filter, item.file, strict = strict) - } - - // TODO: Implement fuzzy search if strict == false? - val matches = if (strict) item.file.parentFile.name.let { - it == filter || it.endsWith(".$filter") || it.endsWith("://$filter") - } - else "${item.file.relativeTo(root).path}/${item.file.nameWithoutExtension}".toLowerCase( - Locale.getDefault() - ).contains(filter.toLowerCase(Locale.getDefault())) - - val inAdapter = dataSource.contains(item) - if (item.type == PasswordItem.TYPE_PASSWORD && matches && !inAdapter) { - dataSource.add(item) - } else if (!matches && inAdapter) { - dataSource.remove(item) + } else { + // TODO: Implement fuzzy search if strict == false? + val matches = item.file.matches(filter, strict = strict) + val inAdapter = dataSource.contains(item) + if (matches && !inAdapter) { + dataSource.add(item) + } else if (!matches && inAdapter) { + dataSource.remove(item) + } } } } diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt index e5368b73..e5def0fb 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt @@ -18,6 +18,7 @@ import com.github.ajalt.timberkt.e import com.zeapo.pwdstore.PasswordStore import com.zeapo.pwdstore.autofill.oreo.AutofillAction import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher +import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences import com.zeapo.pwdstore.autofill.oreo.Credentials import com.zeapo.pwdstore.autofill.oreo.FillableForm import com.zeapo.pwdstore.autofill.oreo.FormOrigin @@ -32,7 +33,7 @@ class AutofillSaveActivity : Activity() { private const val EXTRA_FOLDER_NAME = "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_FOLDER_NAME" private const val EXTRA_PASSWORD = "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_PASSWORD" - private const val EXTRA_USERNAME = "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_USERNAME" + private const val EXTRA_NAME = "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_NAME" private const val EXTRA_SHOULD_MATCH_APP = "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_SHOULD_MATCH_APP" private const val EXTRA_SHOULD_MATCH_WEB = @@ -48,15 +49,23 @@ class AutofillSaveActivity : Activity() { formOrigin: FormOrigin ): IntentSender { val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false) - val sanitizedIdentifier = identifier.replace("""[\\\/]""", "") - val folderName = - sanitizedIdentifier.takeUnless { it.isBlank() } ?: formOrigin.identifier + // Prevent directory traversals + val sanitizedIdentifier = identifier.replace('\\', '_') + .replace('/', '_') + .trimStart('.') + .takeUnless { it.isBlank() } ?: formOrigin.identifier + val directoryStructure = AutofillPreferences.directoryStructure(context) + val folderName = directoryStructure.getSaveFolderName( + sanitizedIdentifier = sanitizedIdentifier, + username = credentials?.username + ) + val fileName = directoryStructure.getSaveFileName(username = credentials?.username) val intent = Intent(context, AutofillSaveActivity::class.java).apply { putExtras( bundleOf( EXTRA_FOLDER_NAME to folderName, + EXTRA_NAME to fileName, EXTRA_PASSWORD to credentials?.password, - EXTRA_USERNAME to credentials?.username, EXTRA_SHOULD_MATCH_APP to formOrigin.identifier.takeIf { formOrigin is FormOrigin.App }, EXTRA_SHOULD_MATCH_WEB to formOrigin.identifier.takeIf { formOrigin is FormOrigin.Web }, EXTRA_GENERATE_PASSWORD to (credentials == null) @@ -87,15 +96,13 @@ class AutofillSaveActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val repo = PasswordRepository.getRepositoryDirectory(applicationContext) - val username = intent.getStringExtra(EXTRA_USERNAME) - val saveIntent = Intent(this, PgpActivity::class.java).apply { putExtras( bundleOf( "REPO_PATH" to repo.absolutePath, "FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)).absolutePath, "OPERATION" to "ENCRYPT", - "SUGGESTED_NAME" to username, + "SUGGESTED_NAME" to intent.getStringExtra(EXTRA_NAME), "SUGGESTED_PASS" to intent.getStringExtra(EXTRA_PASSWORD), "GENERATE_PASSWORD" to intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) ) diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/PgpActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/PgpActivity.kt index 90efd583..2fb85118 100644 --- a/app/src/main/java/com/zeapo/pwdstore/crypto/PgpActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/crypto/PgpActivity.kt @@ -41,6 +41,8 @@ import com.zeapo.pwdstore.ClipboardService import com.zeapo.pwdstore.PasswordEntry import com.zeapo.pwdstore.R import com.zeapo.pwdstore.UserPreference +import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences +import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure import com.zeapo.pwdstore.ui.dialogs.PasswordGeneratorDialogFragment import com.zeapo.pwdstore.ui.dialogs.XkPasswordGeneratorDialogFragment import com.zeapo.pwdstore.utils.Otp @@ -157,9 +159,23 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { } title = getString(R.string.new_password_title) - crypto_password_category.text = getRelativePath(fullPath, repoPath) - suggestedName?.let { - crypto_password_file_edit.setText(it) + crypto_password_category.apply { + setText(getRelativePath(fullPath, repoPath)) + // If the activity has been provided with suggested info, we allow the user to + // edit the path, otherwise we style the EditText like a TextView. + if (suggestedName != null) { + isEnabled = true + } else { + setBackgroundColor(getColor(android.R.color.transparent)) + } + } + suggestedName?.let { crypto_password_file_edit.setText(it) } + // 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 + // FileBased. + if (suggestedName != null && + AutofillPreferences.directoryStructure(this) == DirectoryStructure.FileBased + ) { encrypt_username.apply { visibility = View.VISIBLE setOnClickListener { @@ -549,7 +565,20 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { val iStream = ByteArrayInputStream(content.toByteArray(Charset.forName("UTF-8"))) val oStream = ByteArrayOutputStream() - val path = if (intent.getBooleanExtra("fromDecrypt", false)) fullPath else "$fullPath/$editName.gpg" + val path = when { + intent.getBooleanExtra("fromDecrypt", false) -> fullPath + // If we allowed the user to edit the relative path, we have to consider it here instead + // of fullPath. + crypto_password_category.isEnabled -> { + val editRelativePath = crypto_password_category.text!!.toString().trim() + if (editRelativePath.isEmpty()) { + showSnackbar(resources.getString(R.string.path_toast_text)) + return + } + "$repoPath/${editRelativePath.trim('/')}/$editName.gpg" + } + else -> "$fullPath/$editName.gpg" + } lifecycleScope.launch(IO) { api?.executeApiAsync(data, iStream, oStream, object : OpenPgpApi.IOpenPgpCallback { @@ -575,9 +604,14 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { } if (shouldGeneratePassword) { + val directoryStructure = + AutofillPreferences.directoryStructure(applicationContext) val entry = PasswordEntry(content) returnIntent.putExtra("PASSWORD", entry.password) - returnIntent.putExtra("USERNAME", entry.username ?: file.nameWithoutExtension) + returnIntent.putExtra( + "USERNAME", + directoryStructure.getUsernameFor(file) + ) } setResult(RESULT_OK, returnIntent) @@ -615,7 +649,7 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { crypto_extra_edit.setText(passwordEntry?.extraContent) crypto_extra_edit.typeface = monoTypeface - crypto_password_category.text = relativeParentPath + crypto_password_category.setText(relativeParentPath) crypto_password_file_edit.setText(name) crypto_password_file_edit.isEnabled = false |