aboutsummaryrefslogtreecommitdiff
path: root/app/src/main/java/dev
diff options
context:
space:
mode:
authorHarsh Shandilya <me@msfjarvis.dev>2020-12-05 06:07:18 +0530
committerGitHub <noreply@github.com>2020-12-05 06:07:18 +0530
commit5e66d99c852ea67a88b650c03b0e8d55e83eccde (patch)
treeaee323a08253fe7b863976eff92a1bd412bd1430 /app/src/main/java/dev
parent8eb55f18a11d6b2f155c7c9d48ca833313ee13db (diff)
Refactor package structure (#1233)
* idea: default test runner to Gradle * Kick off package structure revamp * Reparent all classes under dev.msfjarvis.aps Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
Diffstat (limited to 'app/src/main/java/dev')
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/Application.kt66
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/data/password/PasswordEntry.kt139
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/data/password/PasswordItem.kt86
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/data/repo/PasswordRepository.kt243
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/adapters/PasswordItemRecyclerAdapter.kt83
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt245
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt220
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillPublisherChangedActivity.kt114
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillSaveActivity.kt149
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/autofill/PasswordViewHolder.kt16
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt310
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt260
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt76
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt513
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt161
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/dialogs/FolderCreationDialogFragment.kt106
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/dialogs/ItemCreationBottomSheet.kt77
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt101
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/dialogs/XkPasswordGeneratorDialogFragment.kt131
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderActivity.kt63
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderFragment.kt81
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/git/base/BaseGitActivity.kt146
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitConfigActivity.kt153
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitServerConfigActivity.kt267
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt49
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt58
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/main/LaunchActivity.kt63
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/onboarding/activity/OnboardingActivity.kt26
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/CloneFragment.kt59
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/KeySelectionFragment.kt66
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/RepoLocationFragment.kt192
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/WelcomeFragment.kt30
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt344
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt697
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/proxy/ProxySelectorActivity.kt75
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/settings/UserPreference.kt676
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/ShowSshKeyFragment.kt38
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyGenActivity.kt164
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt71
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt78
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt189
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt193
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt140
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt205
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt113
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt179
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt90
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt36
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt65
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt67
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt117
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/GitCommit.kt18
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt55
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt36
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt22
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt98
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt216
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt31
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt15
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt23
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt23
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt37
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt191
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt93
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt336
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt275
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt197
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt66
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt139
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt33
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt45
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt169
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/CapsType.kt9
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/PasswordBuilder.kt142
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/XkpwdDictionary.kt40
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt183
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt145
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt158
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt181
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt114
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt49
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt85
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/totp/Otp.kt73
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/totp/TotpFinder.kt32
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt66
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt474
86 files changed, 11755 insertions, 0 deletions
diff --git a/app/src/main/java/dev/msfjarvis/aps/Application.kt b/app/src/main/java/dev/msfjarvis/aps/Application.kt
new file mode 100644
index 00000000..ce7164c9
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/Application.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps
+
+import android.content.SharedPreferences
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
+import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
+import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO
+import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES
+import com.github.ajalt.timberkt.Timber.DebugTree
+import com.github.ajalt.timberkt.Timber.plant
+import dev.msfjarvis.aps.util.git.sshj.setUpBouncyCastleForSshj
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.proxy.ProxyUtils
+import dev.msfjarvis.aps.util.extensions.getString
+import dev.msfjarvis.aps.util.extensions.sharedPrefs
+import dev.msfjarvis.aps.util.settings.runMigrations
+
+@Suppress("Unused")
+class Application : android.app.Application(), SharedPreferences.OnSharedPreferenceChangeListener {
+
+ private val prefs by lazy { sharedPrefs }
+
+ override fun onCreate() {
+ super.onCreate()
+ instance = this
+ if (BuildConfig.ENABLE_DEBUG_FEATURES ||
+ prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false)) {
+ plant(DebugTree())
+ }
+ prefs.registerOnSharedPreferenceChangeListener(this)
+ setNightMode()
+ setUpBouncyCastleForSshj()
+ runMigrations(applicationContext)
+ ProxyUtils.setDefaultProxy()
+ }
+
+ override fun onTerminate() {
+ prefs.unregisterOnSharedPreferenceChangeListener(this)
+ super.onTerminate()
+ }
+
+ override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String) {
+ if (key == PreferenceKeys.APP_THEME) {
+ setNightMode()
+ }
+ }
+
+ private fun setNightMode() {
+ AppCompatDelegate.setDefaultNightMode(when (prefs.getString(PreferenceKeys.APP_THEME)
+ ?: getString(R.string.app_theme_def)) {
+ "light" -> MODE_NIGHT_NO
+ "dark" -> MODE_NIGHT_YES
+ "follow_system" -> MODE_NIGHT_FOLLOW_SYSTEM
+ else -> MODE_NIGHT_AUTO_BATTERY
+ })
+ }
+
+ companion object {
+
+ lateinit var instance: Application
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordEntry.kt b/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordEntry.kt
new file mode 100644
index 00000000..3a6d9e2c
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordEntry.kt
@@ -0,0 +1,139 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.data.password
+
+import com.github.michaelbull.result.get
+import dev.msfjarvis.aps.util.totp.Otp
+import dev.msfjarvis.aps.util.totp.TotpFinder
+import dev.msfjarvis.aps.util.totp.UriTotpFinder
+import java.io.ByteArrayOutputStream
+import java.io.UnsupportedEncodingException
+import java.util.Date
+
+/**
+ * A single entry in password store. [totpFinder] is an implementation of [TotpFinder] that let's us
+ * abstract out the Android-specific part and continue testing the class in the JVM.
+ */
+class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTotpFinder()) {
+
+ val password: String
+ val username: String?
+ val digits: String
+ val totpSecret: String?
+ val totpPeriod: Long
+ val totpAlgorithm: String
+ var extraContent: String
+ private set
+
+ @Throws(UnsupportedEncodingException::class)
+ constructor(os: ByteArrayOutputStream) : this(os.toString("UTF-8"), UriTotpFinder())
+
+ init {
+ val (foundPassword, passContent) = findAndStripPassword(content.split("\n".toRegex()))
+ password = foundPassword
+ extraContent = passContent.joinToString("\n")
+ username = findUsername()
+ digits = findOtpDigits(content)
+ totpSecret = findTotpSecret(content)
+ totpPeriod = findTotpPeriod(content)
+ totpAlgorithm = findTotpAlgorithm(content)
+ }
+
+ fun hasExtraContent(): Boolean {
+ return extraContent.isNotEmpty()
+ }
+
+ fun hasTotp(): Boolean {
+ return totpSecret != null
+ }
+
+ fun hasUsername(): Boolean {
+ return username != null
+ }
+
+ fun calculateTotpCode(): String? {
+ if (totpSecret == null)
+ return null
+ return Otp.calculateCode(totpSecret, Date().time / (1000 * totpPeriod), totpAlgorithm, digits).get()
+ }
+
+ val extraContentWithoutAuthData by lazy(LazyThreadSafetyMode.NONE) {
+ var foundUsername = false
+ extraContent.splitToSequence("\n").filter { line ->
+ return@filter when {
+ USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) } && !foundUsername -> {
+ foundUsername = true
+ false
+ }
+ line.startsWith("otpauth://", ignoreCase = true) ||
+ line.startsWith("totp:", ignoreCase = true) -> {
+ false
+ }
+ else -> {
+ true
+ }
+ }
+ }.joinToString(separator = "\n")
+ }
+
+ private fun findUsername(): String? {
+ extraContent.splitToSequence("\n").forEach { line ->
+ for (prefix in USERNAME_FIELDS) {
+ if (line.startsWith(prefix, ignoreCase = true))
+ return line.substring(prefix.length).trimStart()
+ }
+ }
+ return null
+ }
+
+ private fun findAndStripPassword(passContent: List<String>): Pair<String, List<String>> {
+ if (UriTotpFinder.TOTP_FIELDS.any { passContent[0].startsWith(it) }) return Pair("", passContent)
+ for (line in passContent) {
+ for (prefix in PASSWORD_FIELDS) {
+ if (line.startsWith(prefix, ignoreCase = true)) {
+ return Pair(line.substring(prefix.length).trimStart(), passContent.minus(line))
+ }
+ }
+ }
+ return Pair(passContent[0], passContent.minus(passContent[0]))
+ }
+
+ private fun findTotpSecret(decryptedContent: String): String? {
+ return totpFinder.findSecret(decryptedContent)
+ }
+
+ private fun findOtpDigits(decryptedContent: String): String {
+ return totpFinder.findDigits(decryptedContent)
+ }
+
+ private fun findTotpPeriod(decryptedContent: String): Long {
+ return totpFinder.findPeriod(decryptedContent)
+ }
+
+ private fun findTotpAlgorithm(decryptedContent: String): String {
+ return totpFinder.findAlgorithm(decryptedContent)
+ }
+
+ companion object {
+
+ val USERNAME_FIELDS = arrayOf(
+ "login:",
+ "username:",
+ "user:",
+ "account:",
+ "email:",
+ "name:",
+ "handle:",
+ "id:",
+ "identity:"
+ )
+
+ val PASSWORD_FIELDS = arrayOf(
+ "password:",
+ "secret:",
+ "pass:",
+ )
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordItem.kt b/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordItem.kt
new file mode 100644
index 00000000..75fc475b
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordItem.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.data.password
+
+import dev.msfjarvis.aps.ui.crypto.BasePgpActivity
+import java.io.File
+
+data class PasswordItem(
+ val name: String,
+ val parent: PasswordItem? = null,
+ val type: Char,
+ val file: File,
+ val rootDir: File
+) : Comparable<PasswordItem> {
+
+ val fullPathToParent = file.absolutePath
+ .replace(rootDir.absolutePath, "")
+ .replace(file.name, "")
+
+ val longName = BasePgpActivity.getLongName(
+ fullPathToParent,
+ rootDir.absolutePath,
+ toString())
+
+ override fun equals(other: Any?): Boolean {
+ return (other is PasswordItem) && (other.file == file)
+ }
+
+ override fun compareTo(other: PasswordItem): Int {
+ return (type + name).compareTo(other.type + other.name, ignoreCase = true)
+ }
+
+ override fun toString(): String {
+ return name.replace("\\.gpg$".toRegex(), "")
+ }
+
+ override fun hashCode(): Int {
+ return 0
+ }
+
+ companion object {
+
+ const val TYPE_CATEGORY = 'c'
+ const val TYPE_PASSWORD = 'p'
+
+ @JvmStatic
+ fun newCategory(
+ name: String,
+ file: File,
+ parent: PasswordItem,
+ rootDir: File
+ ): PasswordItem {
+ return PasswordItem(name, parent, TYPE_CATEGORY, file, rootDir)
+ }
+
+ @JvmStatic
+ fun newCategory(
+ name: String,
+ file: File,
+ rootDir: File
+ ): PasswordItem {
+ return PasswordItem(name, null, TYPE_CATEGORY, file, rootDir)
+ }
+
+ @JvmStatic
+ fun newPassword(
+ name: String,
+ file: File,
+ parent: PasswordItem,
+ rootDir: File
+ ): PasswordItem {
+ return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir)
+ }
+
+ @JvmStatic
+ fun newPassword(
+ name: String,
+ file: File,
+ rootDir: File
+ ): PasswordItem {
+ return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir)
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/data/repo/PasswordRepository.kt b/app/src/main/java/dev/msfjarvis/aps/data/repo/PasswordRepository.kt
new file mode 100644
index 00000000..fecdba86
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/data/repo/PasswordRepository.kt
@@ -0,0 +1,243 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.data.repo
+
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.core.content.edit
+import com.github.michaelbull.result.getOrElse
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.runCatching
+import dev.msfjarvis.aps.Application
+import dev.msfjarvis.aps.data.password.PasswordItem
+import dev.msfjarvis.aps.util.settings.PasswordSortOrder
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.extensions.getString
+import dev.msfjarvis.aps.util.extensions.sharedPrefs
+import java.io.File
+import java.io.FileFilter
+import java.nio.file.Files
+import java.nio.file.LinkOption
+import org.eclipse.jgit.api.Git
+import org.eclipse.jgit.lib.Repository
+import org.eclipse.jgit.storage.file.FileRepositoryBuilder
+import org.eclipse.jgit.transport.RefSpec
+import org.eclipse.jgit.transport.RemoteConfig
+import org.eclipse.jgit.transport.URIish
+import org.eclipse.jgit.util.FS
+import org.eclipse.jgit.util.FS_POSIX_Java6
+
+object PasswordRepository {
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private class FS_POSIX_Java6_with_optional_symlinks : FS_POSIX_Java6() {
+
+ override fun supportsSymlinks() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
+
+ override fun isSymLink(file: File) = Files.isSymbolicLink(file.toPath())
+
+ override fun readSymLink(file: File) = Files.readSymbolicLink(file.toPath()).toString()
+
+ override fun createSymLink(source: File, target: String) {
+ val sourcePath = source.toPath()
+ if (Files.exists(sourcePath, LinkOption.NOFOLLOW_LINKS))
+ Files.delete(sourcePath)
+ Files.createSymbolicLink(sourcePath, File(target).toPath())
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private class Java7FSFactory : FS.FSFactory() {
+
+ override fun detect(cygwinUsed: Boolean?): FS {
+ return FS_POSIX_Java6_with_optional_symlinks()
+ }
+ }
+
+ private var repository: Repository? = null
+ private val settings by lazy(LazyThreadSafetyMode.NONE) { Application.instance.sharedPrefs }
+ private val filesDir
+ get() = Application.instance.filesDir
+
+ /**
+ * Returns the git repository
+ *
+ * @param localDir needed only on the creation
+ * @return the git repository
+ */
+ @JvmStatic
+ fun getRepository(localDir: File?): Repository? {
+ if (repository == null && localDir != null) {
+ val builder = FileRepositoryBuilder()
+ repository = runCatching {
+ builder.run {
+ gitDir = localDir
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ fs = Java7FSFactory().detect(null)
+ }
+ readEnvironment()
+ }.build()
+ }.getOrElse { e ->
+ e.printStackTrace()
+ null
+ }
+ }
+ return repository
+ }
+
+ @JvmStatic
+ val isInitialized: Boolean
+ get() = repository != null
+
+ @JvmStatic
+ fun isGitRepo(): Boolean {
+ if (repository != null) {
+ return repository!!.objectDatabase.exists()
+ }
+ return false
+ }
+
+ @JvmStatic
+ @Throws(Exception::class)
+ fun createRepository(localDir: File) {
+ localDir.delete()
+
+ Git.init().setDirectory(localDir).call()
+ getRepository(localDir)
+ }
+
+ // TODO add multiple remotes support for pull/push
+ @JvmStatic
+ fun addRemote(name: String, url: String, replace: Boolean = false) {
+ val storedConfig = repository!!.config
+ val remotes = storedConfig.getSubsections("remote")
+
+ if (!remotes.contains(name)) {
+ runCatching {
+ val uri = URIish(url)
+ val refSpec = RefSpec("+refs/head/*:refs/remotes/$name/*")
+
+ val remoteConfig = RemoteConfig(storedConfig, name)
+ remoteConfig.addFetchRefSpec(refSpec)
+ remoteConfig.addPushRefSpec(refSpec)
+ remoteConfig.addURI(uri)
+ remoteConfig.addPushURI(uri)
+
+ remoteConfig.update(storedConfig)
+
+ storedConfig.save()
+ }.onFailure { e ->
+ e.printStackTrace()
+ }
+ } else if (replace) {
+ runCatching {
+ val uri = URIish(url)
+
+ val remoteConfig = RemoteConfig(storedConfig, name)
+ // remove the first and eventually the only uri
+ if (remoteConfig.urIs.size > 0) {
+ remoteConfig.removeURI(remoteConfig.urIs[0])
+ }
+ if (remoteConfig.pushURIs.size > 0) {
+ remoteConfig.removePushURI(remoteConfig.pushURIs[0])
+ }
+
+ remoteConfig.addURI(uri)
+ remoteConfig.addPushURI(uri)
+
+ remoteConfig.update(storedConfig)
+
+ storedConfig.save()
+ }.onFailure { e ->
+ e.printStackTrace()
+ }
+ }
+ }
+
+ @JvmStatic
+ fun closeRepository() {
+ if (repository != null) repository!!.close()
+ repository = null
+ }
+
+ @JvmStatic
+ fun getRepositoryDirectory(): File {
+ return if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) {
+ val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
+ if (externalRepo != null)
+ File(externalRepo)
+ else
+ File(filesDir.toString(), "/store")
+ } else {
+ File(filesDir.toString(), "/store")
+ }
+ }
+
+ @JvmStatic
+ fun initialize(): Repository? {
+ val dir = getRepositoryDirectory()
+ // uninitialize the repo if the dir does not exist or is absolutely empty
+ settings.edit {
+ if (!dir.exists() || !dir.isDirectory || requireNotNull(dir.listFiles()).isEmpty()) {
+ putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)
+ } else {
+ putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true)
+ }
+ }
+
+ // create the repository static variable in PasswordRepository
+ return getRepository(File(dir.absolutePath + "/.git"))
+ }
+
+ /**
+ * Gets the .gpg files in a directory
+ *
+ * @param path the directory path
+ * @return the list of gpg files in that directory
+ */
+ @JvmStatic
+ fun getFilesList(path: File?): ArrayList<File> {
+ if (path == null || !path.exists()) return ArrayList()
+
+ val directories = (path.listFiles(FileFilter { pathname -> pathname.isDirectory })
+ ?: emptyArray()).toList()
+ val files = (path.listFiles(FileFilter { pathname -> pathname.extension == "gpg" })
+ ?: emptyArray()).toList()
+
+ val items = ArrayList<File>()
+ items.addAll(directories)
+ items.addAll(files)
+
+ return items
+ }
+
+ /**
+ * Gets the passwords (PasswordItem) in a directory
+ *
+ * @param path the directory path
+ * @return a list of password items
+ */
+ @JvmStatic
+ fun getPasswords(path: File, rootDir: File, sortOrder: PasswordSortOrder): ArrayList<PasswordItem> {
+ // We need to recover the passwords then parse the files
+ val passList = getFilesList(path).also { it.sortBy { f -> f.name } }
+ val passwordList = ArrayList<PasswordItem>()
+ val showHidden = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false)
+
+ if (passList.size == 0) return passwordList
+ if (!showHidden) {
+ passList.filter { !it.isHidden }.toCollection(passList.apply { clear() })
+ }
+ passList.forEach { file ->
+ passwordList.add(if (file.isFile) {
+ PasswordItem.newPassword(file.name, file, rootDir)
+ } else {
+ PasswordItem.newCategory(file.name, file, rootDir)
+ })
+ }
+ passwordList.sortWith(sortOrder.comparator)
+ return passwordList
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/adapters/PasswordItemRecyclerAdapter.kt b/app/src/main/java/dev/msfjarvis/aps/ui/adapters/PasswordItemRecyclerAdapter.kt
new file mode 100644
index 00000000..92b0fe37
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/adapters/PasswordItemRecyclerAdapter.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.adapters
+
+import android.text.SpannableString
+import android.text.style.RelativeSizeSpan
+import android.view.MotionEvent
+import android.view.View
+import androidx.appcompat.widget.AppCompatImageView
+import androidx.appcompat.widget.AppCompatTextView
+import androidx.recyclerview.selection.ItemDetailsLookup
+import androidx.recyclerview.selection.Selection
+import androidx.recyclerview.widget.RecyclerView
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.data.password.PasswordItem
+import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryAdapter
+import dev.msfjarvis.aps.util.viewmodel.stableId
+
+open class PasswordItemRecyclerAdapter :
+ SearchableRepositoryAdapter<PasswordItemRecyclerAdapter.PasswordItemViewHolder>(
+ R.layout.password_row_layout,
+ ::PasswordItemViewHolder,
+ PasswordItemViewHolder::bind
+ ) {
+
+ fun makeSelectable(recyclerView: RecyclerView) {
+ makeSelectable(recyclerView, ::PasswordItemDetailsLookup)
+ }
+
+ override fun onItemClicked(listener: (holder: PasswordItemViewHolder, item: PasswordItem) -> Unit): PasswordItemRecyclerAdapter {
+ return super.onItemClicked(listener) as PasswordItemRecyclerAdapter
+ }
+
+ override fun onSelectionChanged(listener: (selection: Selection<String>) -> Unit): PasswordItemRecyclerAdapter {
+ return super.onSelectionChanged(listener) as PasswordItemRecyclerAdapter
+ }
+
+ class PasswordItemViewHolder(view: View) : RecyclerView.ViewHolder(view) {
+
+ private val name: AppCompatTextView = itemView.findViewById(R.id.label)
+ private val childCount: AppCompatTextView = itemView.findViewById(R.id.child_count)
+ private val folderIndicator: AppCompatImageView =
+ itemView.findViewById(R.id.folder_indicator)
+ lateinit var itemDetails: ItemDetailsLookup.ItemDetails<String>
+
+ fun bind(item: PasswordItem) {
+ val parentPath = item.fullPathToParent.replace("(^/)|(/$)".toRegex(), "")
+ val source = if (parentPath.isNotEmpty()) {
+ "$parentPath\n$item"
+ } else {
+ "$item"
+ }
+ val spannable = SpannableString(source)
+ spannable.setSpan(RelativeSizeSpan(0.7f), 0, parentPath.length, 0)
+ name.text = spannable
+ if (item.type == PasswordItem.TYPE_CATEGORY) {
+ folderIndicator.visibility = View.VISIBLE
+ val count = item.file.listFiles { path -> path.isDirectory || path.extension == "gpg" }?.size
+ ?: 0
+ childCount.visibility = if (count > 0) View.VISIBLE else View.GONE
+ childCount.text = "$count"
+ } else {
+ childCount.visibility = View.GONE
+ folderIndicator.visibility = View.GONE
+ }
+ itemDetails = object : ItemDetailsLookup.ItemDetails<String>() {
+ override fun getPosition() = absoluteAdapterPosition
+ override fun getSelectionKey() = item.stableId
+ }
+ }
+ }
+
+ class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) :
+ ItemDetailsLookup<String>() {
+
+ override fun getItemDetails(event: MotionEvent): ItemDetails<String>? {
+ val view = recyclerView.findChildViewUnder(event.x, event.y) ?: return null
+ return (recyclerView.getChildViewHolder(view) as PasswordItemViewHolder).itemDetails
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt
new file mode 100644
index 00000000..5d6fb886
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt
@@ -0,0 +1,245 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.autofill
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.IntentSender
+import android.os.Build
+import android.os.Bundle
+import android.view.autofill.AutofillManager
+import android.widget.Toast
+import androidx.activity.result.IntentSenderRequest
+import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult
+import androidx.annotation.RequiresApi
+import androidx.appcompat.app.AppCompatActivity
+import com.github.ajalt.timberkt.d
+import com.github.ajalt.timberkt.e
+import com.github.michaelbull.result.getOrElse
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.onSuccess
+import com.github.michaelbull.result.runCatching
+import com.github.androidpasswordstore.autofillparser.AutofillAction
+import com.github.androidpasswordstore.autofillparser.Credentials
+import dev.msfjarvis.aps.util.autofill.AutofillPreferences
+import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder
+import dev.msfjarvis.aps.util.autofill.DirectoryStructure
+import dev.msfjarvis.aps.data.password.PasswordEntry
+import dev.msfjarvis.aps.util.extensions.OPENPGP_PROVIDER
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.InputStream
+import java.io.OutputStream
+import kotlin.coroutines.Continuation
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlin.coroutines.suspendCoroutine
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import me.msfjarvis.openpgpktx.util.OpenPgpApi
+import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
+import org.openintents.openpgp.IOpenPgpService2
+import org.openintents.openpgp.OpenPgpError
+
+@RequiresApi(Build.VERSION_CODES.O)
+class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
+
+ companion object {
+
+ private const val EXTRA_FILE_PATH = "dev.msfjarvis.aps.autofill.oreo.EXTRA_FILE_PATH"
+ private const val EXTRA_SEARCH_ACTION =
+ "dev.msfjarvis.aps.autofill.oreo.EXTRA_SEARCH_ACTION"
+
+ private var decryptFileRequestCode = 1
+
+ fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent {
+ return Intent(context, AutofillDecryptActivity::class.java).apply {
+ putExtras(forwardedExtras)
+ putExtra(EXTRA_SEARCH_ACTION, true)
+ putExtra(EXTRA_FILE_PATH, file.absolutePath)
+ }
+ }
+
+ fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender {
+ val intent = Intent(context, AutofillDecryptActivity::class.java).apply {
+ putExtra(EXTRA_SEARCH_ACTION, false)
+ putExtra(EXTRA_FILE_PATH, file.absolutePath)
+ }
+ return PendingIntent.getActivity(
+ context,
+ decryptFileRequestCode++,
+ intent,
+ PendingIntent.FLAG_CANCEL_CURRENT
+ ).intentSender
+ }
+ }
+
+ private val decryptInteractionRequiredAction = registerForActivityResult(StartIntentSenderForResult()) { result ->
+ if (continueAfterUserInteraction != null) {
+ val data = result.data
+ if (result.resultCode == RESULT_OK && data != null) {
+ continueAfterUserInteraction?.resume(data)
+ } else {
+ continueAfterUserInteraction?.resumeWithException(Exception("OpenPgpApi ACTION_DECRYPT_VERIFY failed to continue after user interaction"))
+ }
+ continueAfterUserInteraction = null
+ }
+ }
+
+ private var continueAfterUserInteraction: Continuation<Intent>? = null
+ private lateinit var directoryStructure: DirectoryStructure
+
+ override val coroutineContext
+ get() = Dispatchers.IO + SupervisorJob()
+
+ override fun onStart() {
+ super.onStart()
+ val filePath = intent?.getStringExtra(EXTRA_FILE_PATH) ?: run {
+ e { "AutofillDecryptActivity started without EXTRA_FILE_PATH" }
+ finish()
+ return
+ }
+ val clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run {
+ e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
+ finish()
+ return
+ }
+ 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 = decryptCredential(File(filePath))
+ if (credentials == null) {
+ setResult(RESULT_CANCELED)
+ } else {
+ val fillInDataset =
+ AutofillResponseBuilder.makeFillInDataset(
+ this@AutofillDecryptActivity,
+ credentials,
+ clientState,
+ action
+ )
+ withContext(Dispatchers.Main) {
+ setResult(RESULT_OK, Intent().apply {
+ putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
+ })
+ }
+ }
+ withContext(Dispatchers.Main) {
+ finish()
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ coroutineContext.cancelChildren()
+ }
+
+ private suspend fun executeOpenPgpApi(
+ data: Intent,
+ input: InputStream,
+ output: OutputStream
+ ): Intent? {
+ var openPgpServiceConnection: OpenPgpServiceConnection? = null
+ val openPgpService = suspendCoroutine<IOpenPgpService2> { cont ->
+ openPgpServiceConnection = OpenPgpServiceConnection(
+ this,
+ OPENPGP_PROVIDER,
+ object : OpenPgpServiceConnection.OnBound {
+ override fun onBound(service: IOpenPgpService2) {
+ cont.resume(service)
+ }
+
+ override fun onError(e: Exception) {
+ cont.resumeWithException(e)
+ }
+ }).also { it.bindToService() }
+ }
+ return OpenPgpApi(this, openPgpService).executeApi(data, input, output).also {
+ openPgpServiceConnection?.unbindFromService()
+ }
+ }
+
+ private suspend fun decryptCredential(
+ file: File,
+ resumeIntent: Intent? = null
+ ): Credentials? {
+ val command = resumeIntent ?: Intent().apply {
+ action = OpenPgpApi.ACTION_DECRYPT_VERIFY
+ }
+ runCatching {
+ file.inputStream()
+ }.onFailure { e ->
+ e(e) { "File to decrypt not found" }
+ return null
+ }.onSuccess { encryptedInput ->
+ val decryptedOutput = ByteArrayOutputStream()
+ runCatching {
+ executeOpenPgpApi(command, encryptedInput, decryptedOutput)
+ }.onFailure { e ->
+ e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed" }
+ return null
+ }.onSuccess { result ->
+ return when (val resultCode =
+ result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
+ OpenPgpApi.RESULT_CODE_SUCCESS -> {
+ runCatching {
+ val entry = withContext(Dispatchers.IO) {
+ @Suppress("BlockingMethodInNonBlockingContext")
+ (PasswordEntry(decryptedOutput))
+ }
+ AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure)
+ }.getOrElse { e ->
+ e(e) { "Failed to parse password entry" }
+ return null
+ }
+ }
+ OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
+ val pendingIntent: PendingIntent =
+ result.getParcelableExtra(OpenPgpApi.RESULT_INTENT)!!
+ runCatching {
+ val intentToResume = withContext(Dispatchers.Main) {
+ suspendCoroutine<Intent> { cont ->
+ continueAfterUserInteraction = cont
+ decryptInteractionRequiredAction.launch(IntentSenderRequest.Builder(pendingIntent.intentSender).build())
+ }
+ }
+ decryptCredential(file, intentToResume)
+ }.getOrElse { e ->
+ e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed with user interaction" }
+ return null
+ }
+ }
+ OpenPgpApi.RESULT_CODE_ERROR -> {
+ val error = result.getParcelableExtra<OpenPgpError>(OpenPgpApi.RESULT_ERROR)
+ if (error != null) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(
+ applicationContext,
+ "Error from OpenKeyChain: ${error.message}",
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ e { "OpenPgpApi ACTION_DECRYPT_VERIFY failed (${error.errorId}): ${error.message}" }
+ }
+ null
+ }
+ else -> {
+ e { "Unrecognized OpenPgpApi result: $resultCode" }
+ null
+ }
+ }
+ }
+ }
+ return null
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt
new file mode 100644
index 00000000..fe19a636
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt
@@ -0,0 +1,220 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.autofill
+
+import android.annotation.TargetApi
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.IntentSender
+import android.os.Build
+import android.os.Bundle
+import android.view.View
+import android.view.autofill.AutofillManager
+import android.widget.TextView
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.text.bold
+import androidx.core.text.buildSpannedString
+import androidx.core.text.underline
+import androidx.core.widget.addTextChangedListener
+import androidx.lifecycle.ViewModelProvider
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.github.ajalt.timberkt.e
+import com.github.androidpasswordstore.autofillparser.FormOrigin
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.util.autofill.AutofillMatcher
+import dev.msfjarvis.aps.util.autofill.AutofillPreferences
+import dev.msfjarvis.aps.util.autofill.DirectoryStructure
+import dev.msfjarvis.aps.databinding.ActivityOreoAutofillFilterBinding
+import dev.msfjarvis.aps.data.password.PasswordItem
+import dev.msfjarvis.aps.util.extensions.viewBinding
+import dev.msfjarvis.aps.util.viewmodel.FilterMode
+import dev.msfjarvis.aps.util.viewmodel.ListMode
+import dev.msfjarvis.aps.util.viewmodel.SearchMode
+import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryAdapter
+import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryViewModel
+
+@TargetApi(Build.VERSION_CODES.O)
+class AutofillFilterView : AppCompatActivity() {
+
+ companion object {
+
+ private const val HEIGHT_PERCENTAGE = 0.9
+ private const val WIDTH_PERCENTAGE = 0.75
+
+ private const val EXTRA_FORM_ORIGIN_WEB =
+ "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_WEB"
+ private const val EXTRA_FORM_ORIGIN_APP =
+ "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP"
+ private var matchAndDecryptFileRequestCode = 1
+
+ fun makeMatchAndDecryptFileIntentSender(
+ context: Context,
+ formOrigin: FormOrigin
+ ): IntentSender {
+ val intent = Intent(context, AutofillFilterView::class.java).apply {
+ when (formOrigin) {
+ is FormOrigin.Web -> putExtra(EXTRA_FORM_ORIGIN_WEB, formOrigin.identifier)
+ is FormOrigin.App -> putExtra(EXTRA_FORM_ORIGIN_APP, formOrigin.identifier)
+ }
+ }
+ return PendingIntent.getActivity(
+ context,
+ matchAndDecryptFileRequestCode++,
+ intent,
+ PendingIntent.FLAG_CANCEL_CURRENT
+ ).intentSender
+ }
+ }
+
+ private lateinit var formOrigin: FormOrigin
+ private lateinit var directoryStructure: DirectoryStructure
+ private val binding by viewBinding(ActivityOreoAutofillFilterBinding::inflate)
+
+ private val model: SearchableRepositoryViewModel by viewModels {
+ ViewModelProvider.AndroidViewModelFactory(application)
+ }
+
+ private val decryptAction = registerForActivityResult(StartActivityForResult()) { result ->
+ if (result.resultCode == RESULT_OK) {
+ setResult(RESULT_OK, result.data)
+ }
+ finish()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(binding.root)
+ setFinishOnTouchOutside(true)
+
+ val params = window.attributes
+ params.height = (HEIGHT_PERCENTAGE * resources.displayMetrics.heightPixels).toInt()
+ params.width = (WIDTH_PERCENTAGE * resources.displayMetrics.widthPixels).toInt()
+ window.attributes = params
+
+ if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true) {
+ e { "AutofillFilterActivity started without EXTRA_CLIENT_STATE" }
+ finish()
+ return
+ }
+ formOrigin = when {
+ intent?.hasExtra(EXTRA_FORM_ORIGIN_WEB) == true -> {
+ FormOrigin.Web(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_WEB)!!)
+ }
+ intent?.hasExtra(EXTRA_FORM_ORIGIN_APP) == true -> {
+ FormOrigin.App(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_APP)!!)
+ }
+ else -> {
+ e { "AutofillFilterActivity started without EXTRA_FORM_ORIGIN_WEB or EXTRA_FORM_ORIGIN_APP" }
+ finish()
+ return
+ }
+ }
+ directoryStructure = AutofillPreferences.directoryStructure(this)
+
+ supportActionBar?.hide()
+ bindUI()
+ updateSearch()
+ setResult(RESULT_CANCELED)
+ }
+
+ private fun bindUI() {
+ with(binding) {
+ rvPassword.apply {
+ adapter = SearchableRepositoryAdapter(
+ R.layout.oreo_autofill_filter_row,
+ ::PasswordViewHolder
+ ) { item ->
+ val file = item.file.relativeTo(item.rootDir)
+ val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file)
+ val identifier = directoryStructure.getIdentifierFor(file)
+ val accountPart = directoryStructure.getAccountPartFor(file)
+ check(identifier != null || accountPart != null) { "At least one of identifier and accountPart should always be non-null" }
+ title.text = if (identifier != null) {
+ buildSpannedString {
+ if (pathToIdentifier != null)
+ append("$pathToIdentifier/")
+ bold { underline { append(identifier) } }
+ }
+ } else {
+ accountPart
+ }
+ subtitle.apply {
+ if (identifier != null && accountPart != null) {
+ text = accountPart
+ visibility = View.VISIBLE
+ } else {
+ visibility = View.GONE
+ }
+ }
+ }.onItemClicked { _, item ->
+ decryptAndFill(item)
+ }
+ layoutManager = LinearLayoutManager(context)
+ }
+ search.apply {
+ val initialSearch =
+ formOrigin.getPrettyIdentifier(applicationContext, untrusted = false)
+ setText(initialSearch, TextView.BufferType.EDITABLE)
+ addTextChangedListener { updateSearch() }
+ }
+ origin.text = buildSpannedString {
+ append(getString(R.string.oreo_autofill_select_and_fill_into))
+ append("\n")
+ bold {
+ append(formOrigin.getPrettyIdentifier(applicationContext, untrusted = true))
+ }
+ }
+ strictDomainSearch.apply {
+ visibility = if (formOrigin is FormOrigin.Web) View.VISIBLE else View.GONE
+ isChecked = formOrigin is FormOrigin.Web
+ setOnCheckedChangeListener { _, _ -> updateSearch() }
+ }
+ shouldMatch.text = getString(
+ R.string.oreo_autofill_match_with,
+ formOrigin.getPrettyIdentifier(applicationContext)
+ )
+ model.searchResult.observe(this@AutofillFilterView) { result ->
+ val list = result.passwordItems
+ (rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) {
+ rvPassword.scrollToPosition(0)
+ }
+ // Switch RecyclerView out for a "no results" message if the new list is empty and
+ // the message is not yet shown (and vice versa).
+ if ((list.isEmpty() && rvPasswordSwitcher.nextView.id == rvPasswordEmpty.id) ||
+ (list.isNotEmpty() && rvPasswordSwitcher.nextView.id == rvPassword.id)
+ ) {
+ rvPasswordSwitcher.showNext()
+ }
+ }
+ }
+ }
+
+ private fun updateSearch() {
+ model.search(
+ binding.search.text.toString().trim(),
+ filterMode = if (binding.strictDomainSearch.isChecked) FilterMode.StrictDomain else FilterMode.Fuzzy,
+ searchMode = SearchMode.RecursivelyInSubdirectories,
+ listMode = ListMode.FilesOnly
+ )
+ }
+
+ private fun decryptAndFill(item: PasswordItem) {
+ if (binding.shouldClear.isChecked) AutofillMatcher.clearMatchesFor(applicationContext, formOrigin)
+ if (binding.shouldMatch.isChecked) AutofillMatcher.addMatchFor(
+ applicationContext,
+ formOrigin,
+ item.file
+ )
+ // intent?.extras? is checked to be non-null in onCreate
+ decryptAction.launch(AutofillDecryptActivity.makeDecryptFileIntent(
+ item.file,
+ intent!!.extras!!,
+ this
+ ))
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillPublisherChangedActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillPublisherChangedActivity.kt
new file mode 100644
index 00000000..a8edabbe
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillPublisherChangedActivity.kt
@@ -0,0 +1,114 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.autofill
+
+import android.annotation.TargetApi
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.IntentSender
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import android.service.autofill.FillResponse
+import android.text.format.DateUtils
+import android.view.View
+import android.view.autofill.AutofillManager
+import androidx.appcompat.app.AppCompatActivity
+import com.github.ajalt.timberkt.e
+import com.github.androidpasswordstore.autofillparser.FormOrigin
+import com.github.androidpasswordstore.autofillparser.computeCertificatesHash
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.runCatching
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.util.autofill.AutofillMatcher
+import dev.msfjarvis.aps.util.autofill.AutofillPublisherChangedException
+import dev.msfjarvis.aps.databinding.ActivityOreoAutofillPublisherChangedBinding
+import dev.msfjarvis.aps.util.extensions.viewBinding
+
+@TargetApi(Build.VERSION_CODES.O)
+class AutofillPublisherChangedActivity : AppCompatActivity() {
+
+ companion object {
+
+ private const val EXTRA_APP_PACKAGE =
+ "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_APP_PACKAGE"
+ private const val EXTRA_FILL_RESPONSE_AFTER_RESET =
+ "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FILL_RESPONSE_AFTER_RESET"
+ private var publisherChangedRequestCode = 1
+
+ fun makePublisherChangedIntentSender(
+ context: Context,
+ publisherChangedException: AutofillPublisherChangedException,
+ fillResponseAfterReset: FillResponse?,
+ ): IntentSender {
+ val intent = Intent(context, AutofillPublisherChangedActivity::class.java).apply {
+ putExtra(EXTRA_APP_PACKAGE, publisherChangedException.formOrigin.identifier)
+ putExtra(EXTRA_FILL_RESPONSE_AFTER_RESET, fillResponseAfterReset)
+ }
+ return PendingIntent.getActivity(
+ context, publisherChangedRequestCode++, intent, PendingIntent.FLAG_CANCEL_CURRENT
+ ).intentSender
+ }
+ }
+
+ private lateinit var appPackage: String
+ private val binding by viewBinding(ActivityOreoAutofillPublisherChangedBinding::inflate)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(binding.root)
+ setFinishOnTouchOutside(true)
+
+ appPackage = intent.getStringExtra(EXTRA_APP_PACKAGE) ?: run {
+ e { "AutofillPublisherChangedActivity started without EXTRA_PACKAGE_NAME" }
+ finish()
+ return
+ }
+ supportActionBar?.hide()
+ showPackageInfo()
+ with(binding) {
+ okButton.setOnClickListener { finish() }
+ advancedButton.setOnClickListener {
+ advancedButton.visibility = View.GONE
+ warningAppAdvancedInfo.visibility = View.VISIBLE
+ resetButton.visibility = View.VISIBLE
+ }
+ resetButton.setOnClickListener {
+ AutofillMatcher.clearMatchesFor(this@AutofillPublisherChangedActivity, FormOrigin.App(appPackage))
+ val fillResponse = intent.getParcelableExtra<FillResponse>(EXTRA_FILL_RESPONSE_AFTER_RESET)
+ setResult(RESULT_OK, Intent().apply {
+ putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse)
+ })
+ finish()
+ }
+ }
+ }
+
+ private fun showPackageInfo() {
+ runCatching {
+ with(binding) {
+ val packageInfo =
+ packageManager.getPackageInfo(appPackage, PackageManager.GET_META_DATA)
+ val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime)
+ warningAppInstallDate.text =
+ getString(R.string.oreo_autofill_warning_publisher_install_time, installTime)
+ val appInfo =
+ packageManager.getApplicationInfo(appPackage, PackageManager.GET_META_DATA)
+ warningAppName.text = "“${packageManager.getApplicationLabel(appInfo)}”"
+
+ val currentHash = computeCertificatesHash(this@AutofillPublisherChangedActivity, appPackage)
+ warningAppAdvancedInfo.text = getString(
+ R.string.oreo_autofill_warning_publisher_advanced_info_template,
+ appPackage,
+ currentHash
+ )
+ }
+ }.onFailure { e ->
+ e(e) { "Failed to retrieve package info for $appPackage" }
+ finish()
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillSaveActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillSaveActivity.kt
new file mode 100644
index 00000000..46234c4f
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillSaveActivity.kt
@@ -0,0 +1,149 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.autofill
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.IntentSender
+import android.os.Build
+import android.os.Bundle
+import android.view.autofill.AutofillManager
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import androidx.annotation.RequiresApi
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.os.bundleOf
+import com.github.ajalt.timberkt.e
+import com.github.androidpasswordstore.autofillparser.AutofillAction
+import com.github.androidpasswordstore.autofillparser.Credentials
+import com.github.androidpasswordstore.autofillparser.FormOrigin
+import dev.msfjarvis.aps.util.autofill.AutofillMatcher
+import dev.msfjarvis.aps.util.autofill.AutofillPreferences
+import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder
+import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivity
+import dev.msfjarvis.aps.data.repo.PasswordRepository
+import java.io.File
+
+@RequiresApi(Build.VERSION_CODES.O)
+class AutofillSaveActivity : AppCompatActivity() {
+
+ companion object {
+
+ private const val EXTRA_FOLDER_NAME =
+ "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FOLDER_NAME"
+ private const val EXTRA_PASSWORD = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_PASSWORD"
+ private const val EXTRA_NAME = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_NAME"
+ private const val EXTRA_SHOULD_MATCH_APP =
+ "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_APP"
+ private const val EXTRA_SHOULD_MATCH_WEB =
+ "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_WEB"
+ private const val EXTRA_GENERATE_PASSWORD =
+ "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_GENERATE_PASSWORD"
+
+ private var saveRequestCode = 1
+
+ fun makeSaveIntentSender(
+ context: Context,
+ credentials: Credentials?,
+ formOrigin: FormOrigin
+ ): IntentSender {
+ val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false)
+ // 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, identifier = identifier)
+ 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_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)
+ )
+ )
+ }
+ return PendingIntent.getActivity(
+ context,
+ saveRequestCode++,
+ intent,
+ PendingIntent.FLAG_CANCEL_CURRENT
+ ).intentSender
+ }
+ }
+
+ private val formOrigin by lazy(LazyThreadSafetyMode.NONE) {
+ val shouldMatchApp: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_APP)
+ val shouldMatchWeb: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_WEB)
+ if (shouldMatchApp != null && shouldMatchWeb == null) {
+ FormOrigin.App(shouldMatchApp)
+ } else if (shouldMatchApp == null && shouldMatchWeb != null) {
+ FormOrigin.Web(shouldMatchWeb)
+ } else {
+ null
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val repo = PasswordRepository.getRepositoryDirectory()
+ val saveIntent = Intent(this, PasswordCreationActivity::class.java).apply {
+ putExtras(
+ bundleOf(
+ "REPO_PATH" to repo.absolutePath,
+ "FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)!!).absolutePath,
+ PasswordCreationActivity.EXTRA_FILE_NAME to intent.getStringExtra(EXTRA_NAME),
+ PasswordCreationActivity.EXTRA_PASSWORD to intent.getStringExtra(EXTRA_PASSWORD),
+ PasswordCreationActivity.EXTRA_GENERATE_PASSWORD to intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
+ )
+ )
+ }
+ registerForActivityResult(StartActivityForResult()) { result ->
+ val data = result.data
+ if (result.resultCode == RESULT_OK && data != null) {
+ val createdPath = data.getStringExtra("CREATED_FILE")!!
+ formOrigin?.let {
+ AutofillMatcher.addMatchFor(this, it, File(createdPath))
+ }
+ val password = data.getStringExtra("PASSWORD")
+ val resultIntent = if (password != null) {
+ // Password was generated and should be filled into a form.
+ val username = data.getStringExtra("USERNAME")
+ val clientState =
+ intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run {
+ e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
+ finish()
+ return@registerForActivityResult
+ }
+ val credentials = Credentials(username, password, null)
+ val fillInDataset = AutofillResponseBuilder.makeFillInDataset(
+ this,
+ credentials,
+ clientState,
+ AutofillAction.Generate
+ )
+ Intent().apply {
+ putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
+ }
+ } else {
+ // Password was extracted from a form, there is nothing to fill.
+ Intent()
+ }
+ setResult(RESULT_OK, resultIntent)
+ } else {
+ setResult(RESULT_CANCELED)
+ }
+ finish()
+ }.launch(saveIntent)
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/PasswordViewHolder.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/PasswordViewHolder.kt
new file mode 100644
index 00000000..e2fbaf76
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/PasswordViewHolder.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.autofill
+
+import android.view.View
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import dev.msfjarvis.aps.R
+
+class PasswordViewHolder(view: View) : RecyclerView.ViewHolder(view) {
+
+ val title: TextView = itemView.findViewById(R.id.title)
+ val subtitle: TextView = itemView.findViewById(R.id.subtitle)
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt
new file mode 100644
index 00000000..e75d6f0a
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt
@@ -0,0 +1,310 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.ui.crypto
+
+import android.app.PendingIntent
+import android.content.ClipData
+import android.content.Intent
+import android.content.IntentSender
+import android.content.SharedPreferences
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.text.format.DateUtils
+import android.view.WindowManager
+import androidx.annotation.CallSuper
+import androidx.annotation.StringRes
+import androidx.appcompat.app.AppCompatActivity
+import com.github.ajalt.timberkt.Timber.tag
+import com.github.ajalt.timberkt.e
+import com.github.ajalt.timberkt.i
+import com.github.michaelbull.result.getOr
+import com.github.michaelbull.result.runCatching
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.snackbar.Snackbar
+import dev.msfjarvis.aps.util.services.ClipboardService
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.util.extensions.OPENPGP_PROVIDER
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.extensions.clipboard
+import dev.msfjarvis.aps.util.extensions.getString
+import dev.msfjarvis.aps.util.extensions.sharedPrefs
+import dev.msfjarvis.aps.util.extensions.snackbar
+import java.io.File
+import me.msfjarvis.openpgpktx.util.OpenPgpApi
+import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
+import org.openintents.openpgp.IOpenPgpService2
+import org.openintents.openpgp.OpenPgpError
+
+@Suppress("Registered")
+open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
+
+ /**
+ * Full path to the repository
+ */
+ val repoPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("REPO_PATH")!! }
+
+ /**
+ * Full path to the password file being worked on
+ */
+ val fullPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("FILE_PATH")!! }
+
+ /**
+ * Name of the password file
+ *
+ * Converts personal/auth.foo.org/john_doe@example.org.gpg to john_doe.example.org
+ */
+ val name: String by lazy(LazyThreadSafetyMode.NONE) { File(fullPath).nameWithoutExtension }
+
+ /**
+ * Get the timestamp for when this file was last modified.
+ */
+ val lastChangedString: CharSequence by lazy(LazyThreadSafetyMode.NONE) {
+ getLastChangedString(
+ intent.getLongExtra(
+ "LAST_CHANGED_TIMESTAMP",
+ -1L
+ )
+ )
+ }
+
+ /**
+ * [SharedPreferences] instance used by subclasses to persist settings
+ */
+ val settings: SharedPreferences by lazy(LazyThreadSafetyMode.NONE) { sharedPrefs }
+
+ /**
+ * Handle to the [OpenPgpApi] instance that is used by subclasses to interface with OpenKeychain.
+ */
+ private var serviceConnection: OpenPgpServiceConnection? = null
+ var api: OpenPgpApi? = null
+
+ /**
+ * A [OpenPgpServiceConnection.OnBound] instance for the last listener that we wish to bind with
+ * in case the previous attempt was cancelled due to missing [OPENPGP_PROVIDER] package.
+ */
+ private var previousListener: OpenPgpServiceConnection.OnBound? = null
+
+ /**
+ * [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots
+ * or recent apps screen.
+ */
+ @CallSuper
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
+ tag(TAG)
+ }
+
+ /**
+ * [onDestroy] handles unbinding from the OpenPgp service linked with [serviceConnection]. This
+ * is annotated with [CallSuper] because it's critical to unbind the service to ensure we're not
+ * leaking things.
+ */
+ @CallSuper
+ override fun onDestroy() {
+ super.onDestroy()
+ serviceConnection?.unbindFromService()
+ previousListener = null
+ }
+
+ /**
+ * [onResume] controls the flow for resumption of a PGP operation that was previously interrupted
+ * by the [OPENPGP_PROVIDER] package being missing.
+ */
+ override fun onResume() {
+ super.onResume()
+ previousListener?.let { bindToOpenKeychain(it) }
+ }
+
+ /**
+ * Sets up [api] once the service is bound. Downstream consumers must call super this to
+ * initialize [api]
+ */
+ @CallSuper
+ override fun onBound(service: IOpenPgpService2) {
+ api = OpenPgpApi(this, service)
+ }
+
+ /**
+ * Mandatory error handling from [OpenPgpServiceConnection.OnBound]. All subclasses must handle
+ * their own errors, and hence this class simply logs and rethrows. Subclasses Must NOT call super.
+ */
+ override fun onError(e: Exception) {
+ e(e) { "Callers must handle their own exceptions" }
+ throw e
+ }
+
+ /**
+ * Method for subclasses to initiate binding with [OpenPgpServiceConnection].
+ */
+ fun bindToOpenKeychain(onBoundListener: OpenPgpServiceConnection.OnBound) {
+ val installed = runCatching {
+ packageManager.getPackageInfo(OPENPGP_PROVIDER, 0)
+ true
+ }.getOr(false)
+ if (!installed) {
+ previousListener = onBoundListener
+ MaterialAlertDialogBuilder(this)
+ .setTitle(getString(R.string.openkeychain_not_installed_title))
+ .setMessage(getString(R.string.openkeychain_not_installed_message))
+ .setPositiveButton(getString(R.string.openkeychain_not_installed_google_play)) { _, _ ->
+ runCatching {
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ data = Uri.parse(getString(R.string.play_deeplink_template, OPENPGP_PROVIDER))
+ setPackage("com.android.vending")
+ }
+ startActivity(intent)
+ }
+ }
+ .setNeutralButton(getString(R.string.openkeychain_not_installed_fdroid)) { _, _ ->
+ runCatching {
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ data = Uri.parse(getString(R.string.fdroid_deeplink_template, OPENPGP_PROVIDER))
+ }
+ startActivity(intent)
+ }
+ }
+ .setOnCancelListener { finish() }
+ .show()
+ return
+ } else {
+ previousListener = null
+ serviceConnection = OpenPgpServiceConnection(this, OPENPGP_PROVIDER, onBoundListener).also {
+ it.bindToService()
+ }
+ }
+ }
+
+ /**
+ * Handle the case where OpenKeychain returns that it needs to interact with the user
+ *
+ * @param result The intent returned by OpenKeychain
+ */
+ fun getUserInteractionRequestIntent(result: Intent): IntentSender {
+ i { "RESULT_CODE_USER_INTERACTION_REQUIRED" }
+ return result.getParcelableExtra<PendingIntent>(OpenPgpApi.RESULT_INTENT)!!.intentSender
+ }
+
+ /**
+ * Gets a relative string describing when this shape was last changed
+ * (e.g. "one hour ago")
+ */
+ private fun getLastChangedString(timeStamp: Long): CharSequence {
+ if (timeStamp < 0) {
+ throw RuntimeException()
+ }
+
+ return DateUtils.getRelativeTimeSpanString(this, timeStamp, true)
+ }
+
+ /**
+ * Base handling of OpenKeychain errors based on the error contained in [result]. Subclasses
+ * can use this when they want to default to sane error handling.
+ */
+ fun handleError(result: Intent) {
+ val error: OpenPgpError? = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR)
+ if (error != null) {
+ when (error.errorId) {
+ OpenPgpError.NO_OR_WRONG_PASSPHRASE -> {
+ snackbar(message = getString(R.string.openpgp_error_wrong_passphrase))
+ }
+ OpenPgpError.NO_USER_IDS -> {
+ snackbar(message = getString(R.string.openpgp_error_no_user_ids))
+ }
+ else -> {
+ snackbar(message = getString(R.string.openpgp_error_unknown, error.message))
+ e { "onError getErrorId: ${error.errorId}" }
+ e { "onError getMessage: ${error.message}" }
+ }
+ }
+ }
+ }
+
+ /**
+ * Copies provided [text] to the clipboard. Shows a [Snackbar] which can be disabled by passing
+ * [showSnackbar] as false.
+ */
+ fun copyTextToClipboard(
+ text: String?,
+ showSnackbar: Boolean = true,
+ @StringRes snackbarTextRes: Int = R.string.clipboard_copied_text
+ ) {
+ val clipboard = clipboard ?: return
+ val clip = ClipData.newPlainText("pgp_handler_result_pm", text)
+ clipboard.setPrimaryClip(clip)
+ if (showSnackbar) {
+ snackbar(message = resources.getString(snackbarTextRes))
+ }
+ }
+
+ /**
+ * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] to
+ * hide the default [Snackbar] and starts off an instance of [ClipboardService] to provide a
+ * way of clearing the clipboard.
+ */
+ fun copyPasswordToClipboard(password: String?) {
+ copyTextToClipboard(password, showSnackbar = false)
+
+ val clearAfter = settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() ?: 45
+
+ if (clearAfter != 0) {
+ val service = Intent(this, ClipboardService::class.java).apply {
+ action = ClipboardService.ACTION_START
+ putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter)
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ startForegroundService(service)
+ } else {
+ startService(service)
+ }
+ snackbar(message = resources.getString(R.string.clipboard_password_toast_text, clearAfter))
+ } else {
+ snackbar(message = resources.getString(R.string.clipboard_password_no_clear_toast_text))
+ }
+ }
+
+ companion object {
+
+ private const val TAG = "APS/BasePgpActivity"
+ const val KEY_PWGEN_TYPE_CLASSIC = "classic"
+ const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd"
+
+ /**
+ * Gets the relative path to the repository
+ */
+ fun getRelativePath(fullPath: String, repositoryPath: String): String =
+ fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/")
+
+ /**
+ * Gets the Parent path, relative to the repository
+ */
+ fun getParentPath(fullPath: String, repositoryPath: String): String {
+ val relativePath = getRelativePath(fullPath, repositoryPath)
+ val index = relativePath.lastIndexOf("/")
+ return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/".replace("/+".toRegex(), "/")
+ }
+
+ /**
+ * /path/to/store/social/facebook.gpg -> social/facebook
+ */
+ @JvmStatic
+ fun getLongName(fullPath: String, repositoryPath: String, basename: String): String {
+ var relativePath = getRelativePath(fullPath, repositoryPath)
+ return if (relativePath.isNotEmpty() && relativePath != "/") {
+ // remove preceding '/'
+ relativePath = relativePath.substring(1)
+ if (relativePath.endsWith('/')) {
+ relativePath + basename
+ } else {
+ "$relativePath/$basename"
+ }
+ } else {
+ basename
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt
new file mode 100644
index 00000000..8f36cd25
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt
@@ -0,0 +1,260 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.ui.crypto
+
+import android.content.Intent
+import android.graphics.Typeface
+import android.os.Bundle
+import android.text.method.PasswordTransformationMethod
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import androidx.activity.result.IntentSenderRequest
+import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult
+import androidx.lifecycle.lifecycleScope
+import com.github.ajalt.timberkt.e
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.runCatching
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.databinding.DecryptLayoutBinding
+import dev.msfjarvis.aps.data.password.PasswordEntry
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.extensions.viewBinding
+import java.io.ByteArrayOutputStream
+import java.io.File
+import kotlin.time.ExperimentalTime
+import kotlin.time.seconds
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import me.msfjarvis.openpgpktx.util.OpenPgpApi
+import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
+import org.openintents.openpgp.IOpenPgpService2
+
+class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
+
+ private val binding by viewBinding(DecryptLayoutBinding::inflate)
+
+ private val relativeParentPath by lazy(LazyThreadSafetyMode.NONE) { getParentPath(fullPath, repoPath) }
+ private var passwordEntry: PasswordEntry? = null
+
+ private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result ->
+ if (result.data == null) {
+ setResult(RESULT_CANCELED, null)
+ finish()
+ return@registerForActivityResult
+ }
+
+ when (result.resultCode) {
+ RESULT_OK -> decryptAndVerify(result.data)
+ RESULT_CANCELED -> {
+ setResult(RESULT_CANCELED, result.data)
+ finish()
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ bindToOpenKeychain(this)
+ title = name
+ with(binding) {
+ setContentView(root)
+ passwordCategory.text = relativeParentPath
+ passwordFile.text = name
+ passwordFile.setOnLongClickListener {
+ copyTextToClipboard(name)
+ true
+ }
+ passwordLastChanged.run {
+ runCatching {
+ text = resources.getString(R.string.last_changed, lastChangedString)
+ }.onFailure {
+ visibility = View.GONE
+ }
+ }
+ }
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu?): Boolean {
+ menuInflater.inflate(R.menu.pgp_handler, menu)
+ passwordEntry?.let { entry ->
+ if (menu != null) {
+ menu.findItem(R.id.edit_password).isVisible = true
+ if (entry.password.isNotEmpty()) {
+ menu.findItem(R.id.share_password_as_plaintext).isVisible = true
+ menu.findItem(R.id.copy_password).isVisible = true
+ }
+ }
+ }
+ return true
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ when (item.itemId) {
+ android.R.id.home -> onBackPressed()
+ R.id.edit_password -> editPassword()
+ R.id.share_password_as_plaintext -> shareAsPlaintext()
+ R.id.copy_password -> copyPasswordToClipboard(passwordEntry?.password)
+ }
+ return super.onOptionsItemSelected(item)
+ }
+
+ override fun onBound(service: IOpenPgpService2) {
+ super.onBound(service)
+ decryptAndVerify()
+ }
+
+ override fun onError(e: Exception) {
+ e(e)
+ }
+
+ /**
+ * Automatically finishes the activity 60 seconds after decryption succeeded to prevent
+ * information leaks from stale activities.
+ */
+ @OptIn(ExperimentalTime::class)
+ private fun startAutoDismissTimer() {
+ lifecycleScope.launch {
+ delay(60.seconds)
+ finish()
+ }
+ }
+
+ /**
+ * Edit the current password and hide all the fields populated by encrypted data so that when
+ * the result triggers they can be repopulated with new data.
+ */
+ private fun editPassword() {
+ val intent = Intent(this, PasswordCreationActivity::class.java)
+ intent.putExtra("FILE_PATH", relativeParentPath)
+ intent.putExtra("REPO_PATH", repoPath)
+ intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name)
+ intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password)
+ intent.putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, passwordEntry?.extraContent)
+ intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true)
+ startActivity(intent)
+ finish()
+ }
+
+ private fun shareAsPlaintext() {
+ val sendIntent = Intent().apply {
+ action = Intent.ACTION_SEND
+ putExtra(Intent.EXTRA_TEXT, passwordEntry?.password)
+ type = "text/plain"
+ }
+ // Always show a picker to give the user a chance to cancel
+ startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to)))
+ }
+
+ @OptIn(ExperimentalTime::class)
+ private fun decryptAndVerify(receivedIntent: Intent? = null) {
+ if (api == null) {
+ bindToOpenKeychain(this)
+ return
+ }
+ val data = receivedIntent ?: Intent()
+ data.action = OpenPgpApi.ACTION_DECRYPT_VERIFY
+
+ val inputStream = File(fullPath).inputStream()
+ val outputStream = ByteArrayOutputStream()
+
+ lifecycleScope.launch(Dispatchers.IO) {
+ api?.executeApiAsync(data, inputStream, outputStream) { result ->
+ when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
+ OpenPgpApi.RESULT_CODE_SUCCESS -> {
+ startAutoDismissTimer()
+ runCatching {
+ 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)
+
+ passwordEntry = entry
+ invalidateOptionsMenu()
+
+ 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)) {
+ copyPasswordToClipboard(entry.password)
+ }
+ }.onFailure { e ->
+ e(e)
+ }
+ }
+ OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
+ val sender = getUserInteractionRequestIntent(result)
+ userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build())
+ }
+ OpenPgpApi.RESULT_CODE_ERROR -> handleError(result)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt
new file mode 100644
index 00000000..f49537aa
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.ui.crypto
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.result.IntentSenderRequest
+import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult
+import androidx.lifecycle.lifecycleScope
+import com.github.ajalt.timberkt.e
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.runCatching
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import me.msfjarvis.openpgpktx.util.OpenPgpApi
+import me.msfjarvis.openpgpktx.util.OpenPgpUtils
+import org.openintents.openpgp.IOpenPgpService2
+
+class GetKeyIdsActivity : BasePgpActivity() {
+
+ private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result ->
+ if (result.data == null || result.resultCode == RESULT_CANCELED) {
+ setResult(RESULT_CANCELED, result.data)
+ finish()
+ return@registerForActivityResult
+ }
+ getKeyIds(result.data!!)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ bindToOpenKeychain(this)
+ }
+
+ override fun onBound(service: IOpenPgpService2) {
+ super.onBound(service)
+ getKeyIds()
+ }
+
+ override fun onError(e: Exception) {
+ e(e)
+ }
+
+ /**
+ * Get the Key ids from OpenKeychain
+ */
+ private fun getKeyIds(data: Intent = Intent()) {
+ data.action = OpenPgpApi.ACTION_GET_KEY_IDS
+ lifecycleScope.launch(Dispatchers.IO) {
+ api?.executeApiAsync(data, null, null) { result ->
+ when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
+ OpenPgpApi.RESULT_CODE_SUCCESS -> {
+ runCatching {
+ val ids = result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS)?.map {
+ OpenPgpUtils.convertKeyIdToHex(it)
+ } ?: emptyList()
+ val keyResult = Intent().putExtra(OpenPgpApi.EXTRA_KEY_IDS, ids.toTypedArray())
+ setResult(RESULT_OK, keyResult)
+ finish()
+ }.onFailure { e ->
+ e(e)
+ }
+ }
+ OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
+ val sender = getUserInteractionRequestIntent(result)
+ userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build())
+ }
+ OpenPgpApi.RESULT_CODE_ERROR -> handleError(result)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt
new file mode 100644
index 00000000..403f6e2a
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt
@@ -0,0 +1,513 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.ui.crypto
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.text.InputType
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import androidx.activity.result.IntentSenderRequest
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult
+import androidx.core.content.edit
+import androidx.core.view.isVisible
+import androidx.core.widget.doOnTextChanged
+import androidx.lifecycle.lifecycleScope
+import com.github.ajalt.timberkt.e
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.onSuccess
+import com.github.michaelbull.result.runCatching
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.zxing.integration.android.IntentIntegrator
+import com.google.zxing.integration.android.IntentIntegrator.QR_CODE
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.data.password.PasswordEntry
+import dev.msfjarvis.aps.util.autofill.AutofillPreferences
+import dev.msfjarvis.aps.util.autofill.DirectoryStructure
+import dev.msfjarvis.aps.ui.dialogs.PasswordGeneratorDialogFragment
+import dev.msfjarvis.aps.ui.dialogs.XkPasswordGeneratorDialogFragment
+import dev.msfjarvis.aps.data.repo.PasswordRepository
+import dev.msfjarvis.aps.databinding.PasswordCreationActivityBinding
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.extensions.base64
+import dev.msfjarvis.aps.util.extensions.commitChange
+import dev.msfjarvis.aps.util.extensions.getString
+import dev.msfjarvis.aps.util.extensions.isInsideRepository
+import dev.msfjarvis.aps.util.extensions.snackbar
+import dev.msfjarvis.aps.util.extensions.viewBinding
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.IOException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import me.msfjarvis.openpgpktx.util.OpenPgpApi
+import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
+import me.msfjarvis.openpgpktx.util.OpenPgpUtils
+
+class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
+
+ private val binding by viewBinding(PasswordCreationActivityBinding::inflate)
+
+ private val suggestedName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) }
+ private val suggestedPass by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_PASSWORD) }
+ private val suggestedExtra by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_EXTRA_CONTENT) }
+ private val shouldGeneratePassword by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) }
+ private val editing by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_EDITING, false) }
+ private val oldFileName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) }
+ private var oldCategory: String? = null
+ private var copy: Boolean = false
+ private var encryptionIntent: Intent = Intent()
+
+ private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result ->
+ if (result.data == null) {
+ setResult(RESULT_CANCELED, null)
+ finish()
+ return@registerForActivityResult
+ }
+
+ when (result.resultCode) {
+ RESULT_OK -> encrypt(result.data)
+ RESULT_CANCELED -> {
+ setResult(RESULT_CANCELED, result.data)
+ finish()
+ }
+ }
+ }
+
+ private val otpImportAction = registerForActivityResult(StartActivityForResult()) { result ->
+ if (result.resultCode == RESULT_OK) {
+ binding.otpImportButton.isVisible = false
+ val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data)
+ val contents = "${intentResult.contents}\n"
+ val currentExtras = binding.extraContent.text.toString()
+ if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
+ binding.extraContent.append("\n$contents")
+ else
+ binding.extraContent.append(contents)
+ snackbar(message = getString(R.string.otp_import_success))
+ } else {
+ snackbar(message = getString(R.string.otp_import_failure))
+ }
+ }
+
+ private val gpgKeySelectAction = registerForActivityResult(StartActivityForResult()) { result ->
+ if (result.resultCode == RESULT_OK) {
+ result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
+ lifecycleScope.launch {
+ val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id")
+ withContext(Dispatchers.IO) {
+ gpgIdentifierFile.writeText(keyIds.joinToString("\n"))
+ }
+ commitChange(getString(
+ R.string.git_commit_gpg_id,
+ getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name)
+ )).onSuccess {
+ encrypt(encryptionIntent)
+ }
+ }
+ }
+ }
+ }
+
+ private fun File.findTillRoot(fileName: String, rootPath: File): File? {
+ val gpgFile = File(this, fileName)
+ if (gpgFile.exists()) return gpgFile
+
+ if (this.absolutePath == rootPath.absolutePath) {
+ return null
+ }
+
+ val parent = parentFile
+ return if (parent != null && parent.exists()) {
+ parent.findTillRoot(fileName, rootPath)
+ } else {
+ null
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ bindToOpenKeychain(this)
+ title = if (editing)
+ getString(R.string.edit_password)
+ else
+ getString(R.string.new_password_title)
+ with(binding) {
+ setContentView(root)
+ generatePassword.setOnClickListener { generatePassword() }
+ otpImportButton.setOnClickListener {
+ otpImportAction.launch(IntentIntegrator(this@PasswordCreationActivity)
+ .setOrientationLocked(false)
+ .setBeepEnabled(false)
+ .setDesiredBarcodeFormats(QR_CODE)
+ .createScanIntent()
+ )
+ }
+
+ directoryInputLayout.apply {
+ if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) {
+ isEnabled = true
+ } else {
+ setBackgroundColor(getColor(android.R.color.transparent))
+ }
+ val path = getRelativePath(fullPath, repoPath)
+ // Keep empty path field visible if it is editable.
+ if (path.isEmpty() && !isEnabled)
+ visibility = View.GONE
+ else {
+ directory.setText(path)
+ oldCategory = path
+ }
+ }
+ if (suggestedName != null) {
+ filename.setText(suggestedName)
+ } else {
+ filename.requestFocus()
+ }
+ // 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@PasswordCreationActivity) ==
+ DirectoryStructure.FileBased
+ ) {
+ encryptUsername.apply {
+ visibility = View.VISIBLE
+ setOnClickListener {
+ if (isChecked) {
+ // User wants to enable username encryption, so we add it to the
+ // encrypted extras as the first line.
+ val username = filename.text.toString()
+ val extras = "username:$username\n${extraContent.text}"
+
+ filename.text?.clear()
+ extraContent.setText(extras)
+ } else {
+ // User wants to disable username encryption, so we extract the
+ // username from the encrypted extras and use it as the filename.
+ val entry = PasswordEntry("PASSWORD\n${extraContent.text}")
+ val username = entry.username
+
+ // 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)
+ }
+ }
+ }
+ }
+ listOf(filename, extraContent).forEach {
+ it.doOnTextChanged { _, _, _, _ -> updateViewState() }
+ }
+ }
+ suggestedPass?.let {
+ password.setText(it)
+ password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
+ }
+ suggestedExtra?.let { extraContent.setText(it) }
+ if (shouldGeneratePassword) {
+ generatePassword()
+ password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
+ }
+ }
+ updateViewState()
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu?): Boolean {
+ menuInflater.inflate(R.menu.pgp_handler_new_password, menu)
+ return true
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ when (item.itemId) {
+ android.R.id.home -> {
+ setResult(RESULT_CANCELED)
+ finish()
+ }
+ R.id.save_password -> {
+ copy = false
+ encrypt()
+ }
+ R.id.save_and_copy_password -> {
+ copy = true
+ encrypt()
+ }
+ else -> return super.onOptionsItemSelected(item)
+ }
+ return true
+ }
+
+ private fun generatePassword() {
+ when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_CLASSIC) {
+ KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment()
+ .show(supportFragmentManager, "generator")
+ KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment()
+ .show(supportFragmentManager, "xkpwgenerator")
+ }
+ }
+
+ private fun updateViewState() = with(binding) {
+ // Use PasswordEntry to parse extras for username
+ val entry = PasswordEntry("PLACEHOLDER\n${extraContent.text}")
+ encryptUsername.apply {
+ if (visibility != View.VISIBLE)
+ return@apply
+ val hasUsernameInFileName = filename.text.toString().isNotBlank()
+ val hasUsernameInExtras = entry.hasUsername()
+ isEnabled = hasUsernameInFileName xor hasUsernameInExtras
+ isChecked = hasUsernameInExtras
+ }
+ otpImportButton.isVisible = !entry.hasTotp()
+ }
+
+ private sealed class GpgIdentifier {
+ data class KeyId(val id: Long) : GpgIdentifier()
+ data class UserId(val email: String) : GpgIdentifier()
+ }
+
+ @OptIn(ExperimentalUnsignedTypes::class)
+ private fun parseGpgIdentifier(identifier: String): GpgIdentifier? {
+ if (identifier.isEmpty()) return null
+ // Match long key IDs:
+ // FF22334455667788 or 0xFF22334455667788
+ val maybeLongKeyId = identifier.removePrefix("0x").takeIf {
+ it.matches("[a-fA-F0-9]{16}".toRegex())
+ }
+ if (maybeLongKeyId != null) {
+ val keyId = maybeLongKeyId.toULong(16)
+ return GpgIdentifier.KeyId(keyId.toLong())
+ }
+
+ // Match fingerprints:
+ // FF223344556677889900112233445566778899 or 0xFF223344556677889900112233445566778899
+ val maybeFingerprint = identifier.removePrefix("0x").takeIf {
+ it.matches("[a-fA-F0-9]{40}".toRegex())
+ }
+ if (maybeFingerprint != null) {
+ // Truncating to the long key ID is not a security issue since OpenKeychain only accepts
+ // non-ambiguous key IDs.
+ val keyId = maybeFingerprint.takeLast(16).toULong(16)
+ return GpgIdentifier.KeyId(keyId.toLong())
+ }
+
+ return OpenPgpUtils.splitUserId(identifier).email?.let { GpgIdentifier.UserId(it) }
+ }
+
+ /**
+ * Encrypts the password and the extra content
+ */
+ private fun encrypt(receivedIntent: Intent? = null) {
+ with(binding) {
+ val editName = filename.text.toString().trim()
+ val editPass = password.text.toString()
+ val editExtra = extraContent.text.toString()
+
+ if (editName.isEmpty()) {
+ snackbar(message = resources.getString(R.string.file_toast_text))
+ return@with
+ } else if (editName.contains('/')) {
+ snackbar(message = resources.getString(R.string.invalid_filename_text))
+ return@with
+ }
+
+ if (editPass.isEmpty() && editExtra.isEmpty()) {
+ snackbar(message = resources.getString(R.string.empty_toast_text))
+ return@with
+ }
+
+ if (copy) {
+ copyPasswordToClipboard(editPass)
+ }
+
+ encryptionIntent = receivedIntent ?: Intent()
+ encryptionIntent.action = OpenPgpApi.ACTION_ENCRYPT
+
+ // pass enters the key ID into `.gpg-id`.
+ val repoRoot = PasswordRepository.getRepositoryDirectory()
+ val gpgIdentifierFile = File(repoRoot, directory.text.toString()).findTillRoot(".gpg-id", repoRoot)
+ if (gpgIdentifierFile == null) {
+ snackbar(message = resources.getString(R.string.failed_to_find_key_id))
+ return@with
+ }
+ val gpgIdentifiers = gpgIdentifierFile.readLines()
+ .filter { it.isNotBlank() }
+ .map { line ->
+ parseGpgIdentifier(line) ?: run {
+ // The line being empty means this is most likely an empty `.gpg-id` file
+ // we created. Skip the validation so we can make the user add a real ID.
+ if (line.isEmpty()) return@run
+ if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) {
+ snackbar(message = resources.getString(R.string.short_key_ids_unsupported))
+ } else {
+ snackbar(message = resources.getString(R.string.invalid_gpg_id))
+ }
+ return@with
+ }
+ }
+ if (gpgIdentifiers.isEmpty()) {
+ gpgKeySelectAction.launch(Intent(this@PasswordCreationActivity, GetKeyIdsActivity::class.java))
+ return@with
+ }
+ val keyIds = gpgIdentifiers.filterIsInstance<GpgIdentifier.KeyId>().map { it.id }.toLongArray()
+ if (keyIds.isNotEmpty()) {
+ encryptionIntent.putExtra(OpenPgpApi.EXTRA_KEY_IDS, keyIds)
+ }
+ val userIds = gpgIdentifiers.filterIsInstance<GpgIdentifier.UserId>().map { it.email }.toTypedArray()
+ if (userIds.isNotEmpty()) {
+ encryptionIntent.putExtra(OpenPgpApi.EXTRA_USER_IDS, userIds)
+ }
+
+ encryptionIntent.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true)
+
+ val content = "$editPass\n$editExtra"
+ val inputStream = ByteArrayInputStream(content.toByteArray())
+ val outputStream = ByteArrayOutputStream()
+
+ val path = when {
+ // If we allowed the user to edit the relative path, we have to consider it here instead
+ // of fullPath.
+ directoryInputLayout.isEnabled -> {
+ val editRelativePath = directory.text.toString().trim()
+ if (editRelativePath.isEmpty()) {
+ snackbar(message = resources.getString(R.string.path_toast_text))
+ return
+ }
+ val passwordDirectory = File("$repoPath/${editRelativePath.trim('/')}")
+ if (!passwordDirectory.exists() && !passwordDirectory.mkdir()) {
+ snackbar(message = "Failed to create directory ${editRelativePath.trim('/')}")
+ return
+ }
+
+ "${passwordDirectory.path}/$editName.gpg"
+ }
+ else -> "$fullPath/$editName.gpg"
+ }
+
+ lifecycleScope.launch(Dispatchers.IO) {
+ api?.executeApiAsync(encryptionIntent, inputStream, outputStream) { result ->
+ when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
+ OpenPgpApi.RESULT_CODE_SUCCESS -> {
+ runCatching {
+ val file = File(path)
+ // If we're not editing, this file should not already exist!
+ if (!editing && file.exists()) {
+ snackbar(message = getString(R.string.password_creation_duplicate_error))
+ return@executeApiAsync
+ }
+
+ if (!file.isInsideRepository()) {
+ snackbar(message = getString(R.string.message_error_destination_outside_repo))
+ return@executeApiAsync
+ }
+
+ file.outputStream().use {
+ it.write(outputStream.toByteArray())
+ }
+
+ //associate the new password name with the last name's timestamp in history
+ val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
+ val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg".base64()
+ val timestamp = preference.getString(oldFilePathHash)
+ if (timestamp != null) {
+ preference.edit {
+ remove(oldFilePathHash)
+ putString(file.absolutePath.base64(), timestamp)
+ }
+ }
+
+ val returnIntent = Intent()
+ returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path)
+ returnIntent.putExtra(RETURN_EXTRA_NAME, editName)
+ returnIntent.putExtra(RETURN_EXTRA_LONG_NAME, getLongName(fullPath, repoPath, editName))
+
+ if (shouldGeneratePassword) {
+ val directoryStructure =
+ AutofillPreferences.directoryStructure(applicationContext)
+ val entry = PasswordEntry(content)
+ returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password)
+ val username = entry.username
+ ?: directoryStructure.getUsernameFor(file)
+ returnIntent.putExtra(RETURN_EXTRA_USERNAME, username)
+ }
+
+ if (directoryInputLayout.isVisible && directoryInputLayout.isEnabled && oldFileName != null) {
+ val oldFile = File("$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg")
+ if (oldFile.path != file.path && !oldFile.delete()) {
+ setResult(RESULT_CANCELED)
+ MaterialAlertDialogBuilder(this@PasswordCreationActivity)
+ .setTitle(R.string.password_creation_file_fail_title)
+ .setMessage(getString(R.string.password_creation_file_delete_fail_message, oldFileName))
+ .setCancelable(false)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ finish()
+ }
+ .show()
+ return@executeApiAsync
+ }
+ }
+
+ val commitMessageRes = if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text
+ lifecycleScope.launch {
+ commitChange(resources.getString(
+ commitMessageRes,
+ getLongName(fullPath, repoPath, editName)
+ )).onSuccess {
+ setResult(RESULT_OK, returnIntent)
+ finish()
+ }
+ }
+
+ }.onFailure { e ->
+ if (e is IOException) {
+ e(e) { "Failed to write password file" }
+ setResult(RESULT_CANCELED)
+ MaterialAlertDialogBuilder(this@PasswordCreationActivity)
+ .setTitle(getString(R.string.password_creation_file_fail_title))
+ .setMessage(getString(R.string.password_creation_file_write_fail_message))
+ .setCancelable(false)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ finish()
+ }
+ .show()
+ } else {
+ e(e)
+ }
+ }
+ }
+ OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
+ val sender = getUserInteractionRequestIntent(result)
+ userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build())
+ }
+ OpenPgpApi.RESULT_CODE_ERROR -> handleError(result)
+ }
+ }
+ }
+ }
+ }
+
+ companion object {
+
+ private const val KEY_PWGEN_TYPE_CLASSIC = "classic"
+ private const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd"
+ const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE"
+ const val RETURN_EXTRA_NAME = "NAME"
+ const val RETURN_EXTRA_LONG_NAME = "LONG_NAME"
+ const val RETURN_EXTRA_USERNAME = "USERNAME"
+ const val RETURN_EXTRA_PASSWORD = "PASSWORD"
+ const val EXTRA_FILE_NAME = "FILENAME"
+ const val EXTRA_PASSWORD = "PASSWORD"
+ const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT"
+ const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD"
+ const val EXTRA_EDITING = "EDITING"
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt
new file mode 100644
index 00000000..c2577443
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt
@@ -0,0 +1,161 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.ui.dialogs
+
+import android.content.Context
+import android.graphics.drawable.GradientDrawable
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewTreeObserver
+import android.widget.FrameLayout
+import androidx.annotation.StringRes
+import androidx.core.view.isVisible
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.databinding.BasicBottomSheetBinding
+import dev.msfjarvis.aps.util.extensions.resolveAttribute
+import dev.msfjarvis.aps.util.extensions.viewBinding
+
+/**
+ * [BottomSheetDialogFragment] that exposes a simple [androidx.appcompat.app.AlertDialog] like
+ * API through [Builder] to create a similar UI, just at the bottom of the screen.
+ */
+class BasicBottomSheet private constructor(
+ val title: String?,
+ val message: String,
+ val positiveButtonLabel: String?,
+ val negativeButtonLabel: String?,
+ val positiveButtonClickListener: View.OnClickListener?,
+ val negativeButtonClickListener: View.OnClickListener?,
+) : BottomSheetDialogFragment() {
+
+ private val binding by viewBinding(BasicBottomSheetBinding::bind)
+
+ private var behavior: BottomSheetBehavior<FrameLayout>? = null
+ private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
+ override fun onSlide(bottomSheet: View, slideOffset: Float) {
+ }
+
+ override fun onStateChanged(bottomSheet: View, newState: Int) {
+ if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
+ dismiss()
+ }
+ }
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ if (savedInstanceState != null) dismiss()
+ return layoutInflater.inflate(R.layout.basic_bottom_sheet, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
+ override fun onGlobalLayout() {
+ view.viewTreeObserver.removeOnGlobalLayoutListener(this)
+ val dialog = dialog as BottomSheetDialog? ?: return
+ behavior = dialog.behavior
+ behavior?.apply {
+ state = BottomSheetBehavior.STATE_EXPANDED
+ peekHeight = 0
+ addBottomSheetCallback(bottomSheetCallback)
+ }
+ if (!title.isNullOrEmpty()) {
+ binding.bottomSheetTitle.isVisible = true
+ binding.bottomSheetTitle.text = title
+ }
+ binding.bottomSheetMessage.text = message
+ if (positiveButtonClickListener != null) {
+ positiveButtonLabel?.let { buttonLbl ->
+ binding.bottomSheetOkButton.text = buttonLbl
+ }
+ binding.bottomSheetOkButton.isVisible = true
+ binding.bottomSheetOkButton.setOnClickListener {
+ positiveButtonClickListener.onClick(it)
+ dismiss()
+ }
+ }
+ if (negativeButtonClickListener != null) {
+ binding.bottomSheetCancelButton.isVisible = true
+ negativeButtonLabel?.let { buttonLbl ->
+ binding.bottomSheetCancelButton.text = buttonLbl
+ }
+ binding.bottomSheetCancelButton.setOnClickListener {
+ negativeButtonClickListener.onClick(it)
+ dismiss()
+ }
+ }
+ }
+ })
+ val gradientDrawable = GradientDrawable().apply {
+ setColor(requireContext().resolveAttribute(android.R.attr.windowBackground))
+ }
+ view.background = gradientDrawable
+ }
+
+ override fun dismiss() {
+ super.dismiss()
+ behavior?.removeBottomSheetCallback(bottomSheetCallback)
+ }
+
+ class Builder(val context: Context) {
+
+ private var title: String? = null
+ private var message: String? = null
+ private var positiveButtonLabel: String? = null
+ private var negativeButtonLabel: String? = null
+ private var positiveButtonClickListener: View.OnClickListener? = null
+ private var negativeButtonClickListener: View.OnClickListener? = null
+
+ fun setTitleRes(@StringRes titleRes: Int): Builder {
+ this.title = context.resources.getString(titleRes)
+ return this
+ }
+
+ fun setTitle(title: String): Builder {
+ this.title = title
+ return this
+ }
+
+ fun setMessageRes(@StringRes messageRes: Int): Builder {
+ this.message = context.resources.getString(messageRes)
+ return this
+ }
+
+ fun setMessage(message: String): Builder {
+ this.message = message
+ return this
+ }
+
+ fun setPositiveButtonClickListener(buttonLabel: String? = null, listener: View.OnClickListener): Builder {
+ this.positiveButtonClickListener = listener
+ this.positiveButtonLabel = buttonLabel
+ return this
+ }
+
+ fun setNegativeButtonClickListener(buttonLabel: String? = null, listener: View.OnClickListener): Builder {
+ this.negativeButtonClickListener = listener
+ this.negativeButtonLabel = buttonLabel
+ return this
+ }
+
+ fun build(): BasicBottomSheet {
+ require(message != null) { "Message needs to be set" }
+ return BasicBottomSheet(
+ title,
+ message!!,
+ positiveButtonLabel,
+ negativeButtonLabel,
+ positiveButtonClickListener,
+ negativeButtonClickListener
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/FolderCreationDialogFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/FolderCreationDialogFragment.kt
new file mode 100644
index 00000000..7f50a619
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/FolderCreationDialogFragment.kt
@@ -0,0 +1,106 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.dialogs
+
+import android.app.Dialog
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.os.bundleOf
+import androidx.fragment.app.DialogFragment
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.checkbox.MaterialCheckBox
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.textfield.TextInputEditText
+import com.google.android.material.textfield.TextInputLayout
+import dev.msfjarvis.aps.ui.passwords.PasswordStore
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.ui.crypto.BasePgpActivity
+import dev.msfjarvis.aps.ui.crypto.GetKeyIdsActivity
+import dev.msfjarvis.aps.data.repo.PasswordRepository
+import dev.msfjarvis.aps.util.extensions.commitChange
+import dev.msfjarvis.aps.util.extensions.requestInputFocusOnView
+import java.io.File
+import kotlinx.coroutines.launch
+import me.msfjarvis.openpgpktx.util.OpenPgpApi
+
+class FolderCreationDialogFragment : DialogFragment() {
+
+ private lateinit var newFolder: File
+
+ private val keySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == AppCompatActivity.RESULT_OK) {
+ result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
+ val gpgIdentifierFile = File(newFolder, ".gpg-id")
+ gpgIdentifierFile.writeText(keyIds.joinToString("\n"))
+ val repo = PasswordRepository.getRepository(null)
+ if (repo != null) {
+ lifecycleScope.launch {
+ val repoPath = PasswordRepository.getRepositoryDirectory().absolutePath
+ requireActivity().commitChange(
+ getString(
+ R.string.git_commit_gpg_id,
+ BasePgpActivity.getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name)
+ ),
+ )
+ dismiss()
+ }
+ }
+ }
+ }
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext())
+ alertDialogBuilder.setTitle(R.string.title_create_folder)
+ alertDialogBuilder.setView(R.layout.folder_dialog_fragment)
+ alertDialogBuilder.setPositiveButton(getString(R.string.button_create), null)
+ alertDialogBuilder.setNegativeButton(getString(android.R.string.cancel)) { _, _ ->
+ dismiss()
+ }
+ val dialog = alertDialogBuilder.create()
+ dialog.requestInputFocusOnView<TextInputEditText>(R.id.folder_name_text)
+ dialog.setOnShowListener {
+ dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
+ createDirectory(requireArguments().getString(CURRENT_DIR_EXTRA)!!)
+ }
+ }
+ return dialog
+ }
+
+ private fun createDirectory(currentDir: String) {
+ val dialog = requireDialog()
+ val folderNameView = dialog.findViewById<TextInputEditText>(R.id.folder_name_text)
+ val folderNameViewContainer = dialog.findViewById<TextInputLayout>(R.id.folder_name_container)
+ newFolder = File("$currentDir/${folderNameView.text}")
+ folderNameViewContainer.error = when {
+ newFolder.isFile -> getString(R.string.folder_creation_err_file_exists)
+ newFolder.isDirectory -> getString(R.string.folder_creation_err_folder_exists)
+ else -> null
+ }
+ if (folderNameViewContainer.error != null) return
+ newFolder.mkdirs()
+ (requireActivity() as PasswordStore).refreshPasswordList(newFolder)
+ if (dialog.findViewById<MaterialCheckBox>(R.id.set_gpg_key).isChecked) {
+ keySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java))
+ return
+ } else {
+ dismiss()
+ }
+ }
+
+ companion object {
+
+ private const val CURRENT_DIR_EXTRA = "CURRENT_DIRECTORY"
+ fun newInstance(startingDirectory: String): FolderCreationDialogFragment {
+ val extras = bundleOf(CURRENT_DIR_EXTRA to startingDirectory)
+ val fragment = FolderCreationDialogFragment()
+ fragment.arguments = extras
+ return fragment
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/ItemCreationBottomSheet.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/ItemCreationBottomSheet.kt
new file mode 100644
index 00000000..4889f1e5
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/ItemCreationBottomSheet.kt
@@ -0,0 +1,77 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.dialogs
+
+import android.graphics.drawable.GradientDrawable
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewTreeObserver
+import android.widget.FrameLayout
+import androidx.core.os.bundleOf
+import androidx.fragment.app.setFragmentResult
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import dev.msfjarvis.aps.ui.passwords.PasswordFragment.Companion.ACTION_FOLDER
+import dev.msfjarvis.aps.ui.passwords.PasswordFragment.Companion.ACTION_KEY
+import dev.msfjarvis.aps.ui.passwords.PasswordFragment.Companion.ACTION_PASSWORD
+import dev.msfjarvis.aps.ui.passwords.PasswordFragment.Companion.ITEM_CREATION_REQUEST_KEY
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.util.extensions.resolveAttribute
+
+class ItemCreationBottomSheet : BottomSheetDialogFragment() {
+
+ private var behavior: BottomSheetBehavior<FrameLayout>? = null
+ private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
+ override fun onSlide(bottomSheet: View, slideOffset: Float) {
+ }
+
+ override fun onStateChanged(bottomSheet: View, newState: Int) {
+ if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
+ dismiss()
+ }
+ }
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ if (savedInstanceState != null) dismiss()
+ return inflater.inflate(R.layout.item_create_sheet, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
+ override fun onGlobalLayout() {
+ view.viewTreeObserver.removeOnGlobalLayoutListener(this)
+ val dialog = dialog as BottomSheetDialog? ?: return
+ behavior = dialog.behavior
+ behavior?.apply {
+ state = BottomSheetBehavior.STATE_EXPANDED
+ peekHeight = 0
+ addBottomSheetCallback(bottomSheetCallback)
+ }
+ dialog.findViewById<View>(R.id.create_folder)?.setOnClickListener {
+ setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_FOLDER))
+ dismiss()
+ }
+ dialog.findViewById<View>(R.id.create_password)?.setOnClickListener {
+ setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_PASSWORD))
+ dismiss()
+ }
+ }
+ })
+ val gradientDrawable = GradientDrawable().apply {
+ setColor(requireContext().resolveAttribute(android.R.attr.windowBackground))
+ }
+ view.background = gradientDrawable
+ }
+
+ override fun dismiss() {
+ super.dismiss()
+ behavior?.removeBottomSheetCallback(bottomSheetCallback)
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt
new file mode 100644
index 00000000..0676ef1c
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt
@@ -0,0 +1,101 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.dialogs
+
+import android.annotation.SuppressLint
+import android.app.AlertDialog
+import android.app.Dialog
+import android.content.Context
+import android.graphics.Typeface
+import android.os.Bundle
+import android.widget.CheckBox
+import android.widget.EditText
+import android.widget.Toast
+import androidx.annotation.IdRes
+import androidx.appcompat.widget.AppCompatEditText
+import androidx.appcompat.widget.AppCompatTextView
+import androidx.fragment.app.DialogFragment
+import com.github.michaelbull.result.getOrElse
+import com.github.michaelbull.result.runCatching
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.util.pwgen.PasswordGenerator
+import dev.msfjarvis.aps.util.pwgen.PasswordGenerator.generate
+import dev.msfjarvis.aps.util.pwgen.PasswordGenerator.setPrefs
+import dev.msfjarvis.aps.util.pwgen.PasswordOption
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+
+class PasswordGeneratorDialogFragment : DialogFragment() {
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val callingActivity = requireActivity()
+ val inflater = callingActivity.layoutInflater
+
+ @SuppressLint("InflateParams")
+ val view = inflater.inflate(R.layout.fragment_pwgen, null)
+ val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf")
+ val prefs = requireActivity().applicationContext
+ .getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
+
+ view.findViewById<CheckBox>(R.id.numerals)?.isChecked = !prefs.getBoolean(PasswordOption.NoDigits.key, false)
+ view.findViewById<CheckBox>(R.id.symbols)?.isChecked = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false)
+ view.findViewById<CheckBox>(R.id.uppercase)?.isChecked = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false)
+ view.findViewById<CheckBox>(R.id.lowercase)?.isChecked = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false)
+ view.findViewById<CheckBox>(R.id.ambiguous)?.isChecked = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false)
+ view.findViewById<CheckBox>(R.id.pronounceable)?.isChecked = !prefs.getBoolean(PasswordOption.FullyRandom.key, true)
+
+ val textView: AppCompatEditText = view.findViewById(R.id.lengthNumber)
+ textView.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString())
+ val passwordText: AppCompatTextView = view.findViewById(R.id.passwordText)
+ passwordText.typeface = monoTypeface
+ return MaterialAlertDialogBuilder(requireContext()).run {
+ setTitle(R.string.pwgen_title)
+ setView(view)
+ setPositiveButton(R.string.dialog_ok) { _, _ ->
+ val edit = callingActivity.findViewById<EditText>(R.id.password)
+ edit.setText(passwordText.text)
+ }
+ setNeutralButton(R.string.dialog_cancel) { _, _ -> }
+ setNegativeButton(R.string.pwgen_generate, null)
+ create()
+ }.apply {
+ setOnShowListener {
+ generate(passwordText)
+ getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
+ generate(passwordText)
+ }
+ }
+ }
+ }
+
+ private fun generate(passwordField: AppCompatTextView) {
+ setPreferences()
+ passwordField.text = runCatching {
+ generate(requireContext().applicationContext)
+ }.getOrElse { e ->
+ Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show()
+ ""
+ }
+ }
+
+ private fun isChecked(@IdRes id: Int): Boolean {
+ return requireDialog().findViewById<CheckBox>(id).isChecked
+ }
+
+ private fun setPreferences() {
+ val preferences = listOfNotNull(
+ PasswordOption.NoDigits.takeIf { !isChecked(R.id.numerals) },
+ PasswordOption.AtLeastOneSymbol.takeIf { isChecked(R.id.symbols) },
+ PasswordOption.NoUppercaseLetters.takeIf { !isChecked(R.id.uppercase) },
+ PasswordOption.NoAmbiguousCharacters.takeIf { !isChecked(R.id.ambiguous) },
+ PasswordOption.FullyRandom.takeIf { !isChecked(R.id.pronounceable) },
+ PasswordOption.NoLowercaseLetters.takeIf { !isChecked(R.id.lowercase) }
+ )
+ val lengthText = requireDialog().findViewById<EditText>(R.id.lengthNumber).text.toString()
+ val length = lengthText.toIntOrNull()?.takeIf { it >= 0 }
+ ?: PasswordGenerator.DEFAULT_LENGTH
+ setPrefs(requireActivity().applicationContext, preferences, length)
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/XkPasswordGeneratorDialogFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/XkPasswordGeneratorDialogFragment.kt
new file mode 100644
index 00000000..995c7e2f
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/XkPasswordGeneratorDialogFragment.kt
@@ -0,0 +1,131 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.dialogs
+
+import android.app.Dialog
+import android.content.Context
+import android.content.SharedPreferences
+import android.graphics.Typeface
+import android.os.Bundle
+import android.widget.EditText
+import android.widget.Toast
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.widget.AppCompatTextView
+import androidx.core.content.edit
+import androidx.fragment.app.DialogFragment
+import com.github.ajalt.timberkt.Timber.tag
+import com.github.michaelbull.result.fold
+import com.github.michaelbull.result.getOr
+import com.github.michaelbull.result.runCatching
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.databinding.FragmentXkpwgenBinding
+import dev.msfjarvis.aps.util.pwgenxkpwd.CapsType
+import dev.msfjarvis.aps.util.pwgenxkpwd.PasswordBuilder
+import dev.msfjarvis.aps.util.extensions.getString
+
+/** A placeholder fragment containing a simple view. */
+class XkPasswordGeneratorDialogFragment : DialogFragment() {
+
+ private lateinit var prefs: SharedPreferences
+ private lateinit var binding: FragmentXkpwgenBinding
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val builder = MaterialAlertDialogBuilder(requireContext())
+ val callingActivity = requireActivity()
+ val inflater = callingActivity.layoutInflater
+ binding = FragmentXkpwgenBinding.inflate(inflater)
+
+ val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf")
+
+ builder.setView(binding.root)
+
+ prefs = callingActivity.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
+
+ val previousStoredCapStyle: String = runCatching {
+ prefs.getString(PREF_KEY_CAPITALS_STYLE)!!
+ }.getOr(DEFAULT_CAPS_STYLE)
+
+ val lastCapitalsStyleIndex: Int = runCatching {
+ CapsType.valueOf(previousStoredCapStyle).ordinal
+ }.getOr(DEFAULT_CAPS_INDEX)
+ binding.xkCapType.setSelection(lastCapitalsStyleIndex)
+ binding.xkNumWords.setText(prefs.getString(PREF_KEY_NUM_WORDS, DEFAULT_NUMBER_OF_WORDS))
+
+ binding.xkSeparator.setText(prefs.getString(PREF_KEY_SEPARATOR, DEFAULT_WORD_SEPARATOR))
+ binding.xkNumberSymbolMask.setText(prefs.getString(PREF_KEY_EXTRA_SYMBOLS_MASK, DEFAULT_EXTRA_SYMBOLS_MASK))
+
+ binding.xkPasswordText.typeface = monoTypeface
+
+ builder.setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ ->
+ setPreferences()
+ val edit = callingActivity.findViewById<EditText>(R.id.password)
+ edit.setText(binding.xkPasswordText.text)
+ }
+
+ // flip neutral and negative buttons
+ builder.setNeutralButton(resources.getString(R.string.dialog_cancel)) { _, _ -> }
+ builder.setNegativeButton(resources.getString(R.string.pwgen_generate), null)
+
+ val dialog = builder.setTitle(this.resources.getString(R.string.xkpwgen_title)).create()
+
+ dialog.setOnShowListener {
+ setPreferences()
+ makeAndSetPassword(binding.xkPasswordText)
+
+ dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
+ setPreferences()
+ makeAndSetPassword(binding.xkPasswordText)
+ }
+ }
+ return dialog
+ }
+
+ private fun makeAndSetPassword(passwordText: AppCompatTextView) {
+ PasswordBuilder(requireContext())
+ .setNumberOfWords(Integer.valueOf(binding.xkNumWords.text.toString()))
+ .setMinimumWordLength(DEFAULT_MIN_WORD_LENGTH)
+ .setMaximumWordLength(DEFAULT_MAX_WORD_LENGTH)
+ .setSeparator(binding.xkSeparator.text.toString())
+ .appendNumbers(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_DIGIT })
+ .appendSymbols(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_SYMBOL })
+ .setCapitalization(CapsType.valueOf(binding.xkCapType.selectedItem.toString())).create()
+ .fold(
+ success = { passwordText.text = it },
+ failure = { e ->
+ Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show()
+ tag("xkpw").e(e, "failure generating xkpasswd")
+ passwordText.text = FALLBACK_ERROR_PASS
+ },
+ )
+ }
+
+ private fun setPreferences() {
+ prefs.edit {
+ putString(PREF_KEY_CAPITALS_STYLE, binding.xkCapType.selectedItem.toString())
+ putString(PREF_KEY_NUM_WORDS, binding.xkNumWords.text.toString())
+ putString(PREF_KEY_SEPARATOR, binding.xkSeparator.text.toString())
+ putString(PREF_KEY_EXTRA_SYMBOLS_MASK, binding.xkNumberSymbolMask.text.toString())
+ }
+ }
+
+ companion object {
+
+ const val PREF_KEY_CAPITALS_STYLE = "pref_key_capitals_style"
+ const val PREF_KEY_NUM_WORDS = "pref_key_num_words"
+ const val PREF_KEY_SEPARATOR = "pref_key_separator"
+ const val PREF_KEY_EXTRA_SYMBOLS_MASK = "pref_key_xkpwgen_extra_symbols_mask"
+ val DEFAULT_CAPS_STYLE = CapsType.Sentence.name
+ val DEFAULT_CAPS_INDEX = CapsType.Sentence.ordinal
+ const val DEFAULT_NUMBER_OF_WORDS = "3"
+ const val DEFAULT_WORD_SEPARATOR = "."
+ const val DEFAULT_EXTRA_SYMBOLS_MASK = "ds"
+ const val DEFAULT_MIN_WORD_LENGTH = 3
+ const val DEFAULT_MAX_WORD_LENGTH = 9
+ const val FALLBACK_ERROR_PASS = "42"
+ const val EXTRA_CHAR_PLACEHOLDER_DIGIT = 'd'
+ const val EXTRA_CHAR_PLACEHOLDER_SYMBOL = 's'
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderActivity.kt
new file mode 100644
index 00000000..f2d655bb
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderActivity.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.folderselect
+
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.commit
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.data.repo.PasswordRepository
+import dev.msfjarvis.aps.ui.passwords.PASSWORD_FRAGMENT_TAG
+import dev.msfjarvis.aps.ui.passwords.PasswordStore
+
+
+class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) {
+
+ private lateinit var passwordList: SelectFolderFragment
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ passwordList = SelectFolderFragment()
+ val args = Bundle()
+ args.putString(PasswordStore.REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath)
+
+ passwordList.arguments = args
+
+ supportActionBar?.show()
+
+ supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
+
+ supportFragmentManager.commit {
+ replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG)
+ }
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.pgp_handler_select_folder, menu)
+ return true
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ when (item.itemId) {
+ android.R.id.home -> {
+ setResult(RESULT_CANCELED)
+ finish()
+ return true
+ }
+ R.id.crypto_select -> selectFolder()
+ }
+ return super.onOptionsItemSelected(item)
+ }
+
+ private fun selectFolder() {
+ intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir.absolutePath)
+ setResult(RESULT_OK, intent)
+ finish()
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderFragment.kt
new file mode 100644
index 00000000..597b968c
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderFragment.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.folderselect
+
+import android.content.Context
+import android.os.Bundle
+import android.view.View
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.runCatching
+import dev.msfjarvis.aps.util.viewmodel.ListMode
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryViewModel
+import dev.msfjarvis.aps.databinding.PasswordRecyclerViewBinding
+import dev.msfjarvis.aps.ui.adapters.PasswordItemRecyclerAdapter
+import dev.msfjarvis.aps.data.password.PasswordItem
+import dev.msfjarvis.aps.ui.passwords.PasswordStore
+import dev.msfjarvis.aps.util.extensions.viewBinding
+import java.io.File
+import me.zhanghai.android.fastscroll.FastScrollerBuilder
+
+class SelectFolderFragment : Fragment(R.layout.password_recycler_view) {
+
+ private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
+ private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
+ private lateinit var listener: OnFragmentInteractionListener
+
+ private val model: SearchableRepositoryViewModel by activityViewModels()
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.fab.hide()
+ recyclerAdapter = PasswordItemRecyclerAdapter()
+ .onItemClicked { _, item ->
+ listener.onFragmentInteraction(item)
+ }
+ binding.passRecycler.apply {
+ layoutManager = LinearLayoutManager(requireContext())
+ itemAnimator = null
+ adapter = recyclerAdapter
+ }
+
+ FastScrollerBuilder(binding.passRecycler).build()
+ registerForContextMenu(binding.passRecycler)
+
+ val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH))
+ model.navigateTo(File(path), listMode = ListMode.DirectoriesOnly, pushPreviousLocation = false)
+ model.searchResult.observe(viewLifecycleOwner) { result ->
+ recyclerAdapter.submitList(result.passwordItems)
+ }
+ }
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ runCatching {
+ listener = object : OnFragmentInteractionListener {
+ override fun onFragmentInteraction(item: PasswordItem) {
+ if (item.type == PasswordItem.TYPE_CATEGORY) {
+ model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly)
+ (requireActivity() as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ }
+ }
+ }
+ }.onFailure {
+ throw ClassCastException("$context must implement OnFragmentInteractionListener")
+ }
+ }
+
+ val currentDir: File
+ get() = model.currentDir.value!!
+
+ interface OnFragmentInteractionListener {
+
+ fun onFragmentInteraction(item: PasswordItem)
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/base/BaseGitActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/base/BaseGitActivity.kt
new file mode 100644
index 00000000..89455c90
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/git/base/BaseGitActivity.kt
@@ -0,0 +1,146 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.git.base
+
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.edit
+import com.github.ajalt.timberkt.d
+import com.github.michaelbull.result.Err
+import com.github.michaelbull.result.Result
+import com.github.michaelbull.result.andThen
+import com.github.michaelbull.result.mapError
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.util.settings.GitSettings
+import dev.msfjarvis.aps.util.git.operation.BreakOutOfDetached
+import dev.msfjarvis.aps.util.git.operation.CloneOperation
+import dev.msfjarvis.aps.util.git.operation.PullOperation
+import dev.msfjarvis.aps.util.git.operation.PushOperation
+import dev.msfjarvis.aps.util.git.operation.ResetToRemoteOperation
+import dev.msfjarvis.aps.util.git.operation.SyncOperation
+import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs
+import dev.msfjarvis.aps.util.extensions.sharedPrefs
+import dev.msfjarvis.aps.util.git.ErrorMessages
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import net.schmizz.sshj.common.DisconnectReason
+import net.schmizz.sshj.common.SSHException
+import net.schmizz.sshj.userauth.UserAuthException
+
+/**
+ * Abstract [AppCompatActivity] that holds some information that is commonly shared across git-related
+ * tasks and makes sense to be held here.
+ */
+abstract class BaseGitActivity : ContinuationContainerActivity() {
+
+ /**
+ * Enum of possible Git operations than can be run through [launchGitOperation].
+ */
+ enum class GitOp {
+
+ BREAK_OUT_OF_DETACHED,
+ CLONE,
+ PULL,
+ PUSH,
+ RESET,
+ SYNC,
+ }
+
+ /**
+ * Attempt to launch the requested Git operation.
+ * @param operation The type of git operation to launch
+ */
+ suspend fun launchGitOperation(operation: GitOp): Result<Unit, Throwable> {
+ if (GitSettings.url == null) {
+ return Err(IllegalStateException("Git url is not set!"))
+ }
+ if (operation == GitOp.SYNC && !GitSettings.useMultiplexing) {
+ // If the server does not support multiple SSH channels per connection, we cannot run
+ // a sync operation without reconnecting and thus break sync into its two parts.
+ return launchGitOperation(GitOp.PULL).andThen { launchGitOperation(GitOp.PUSH) }
+ }
+ val op = when (operation) {
+ GitOp.CLONE -> CloneOperation(this, GitSettings.url!!)
+ GitOp.PULL -> PullOperation(this)
+ GitOp.PUSH -> PushOperation(this)
+ GitOp.SYNC -> SyncOperation(this)
+ GitOp.BREAK_OUT_OF_DETACHED -> BreakOutOfDetached(this)
+ GitOp.RESET -> ResetToRemoteOperation(this)
+ }
+ return op.executeAfterAuthentication(GitSettings.authMode).mapError { throwable ->
+ val err = rootCauseException(throwable)
+ if (err.message?.contains("cannot open additional channels") == true) {
+ GitSettings.useMultiplexing = false
+ SSHException(DisconnectReason.TOO_MANY_CONNECTIONS, "The server does not support multiple Git operations per SSH session. Please try again, a slower fallback mode will be used.")
+ } else {
+ err
+ }
+ }
+ }
+
+ fun finishOnSuccessHandler(@Suppress("UNUSED_PARAMETER") nothing: Unit) {
+ finish()
+ }
+
+ suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) {
+ val error = rootCauseException(err)
+ if (!isExplicitlyUserInitiatedError(error)) {
+ getEncryptedGitPrefs().edit {
+ remove(PreferenceKeys.HTTPS_PASSWORD)
+ }
+ sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) }
+ d(error)
+ withContext(Dispatchers.Main) {
+ MaterialAlertDialogBuilder(this@BaseGitActivity).run {
+ setTitle(resources.getString(R.string.jgit_error_dialog_title))
+ setMessage(ErrorMessages[error])
+ setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> }
+ setOnDismissListener {
+ onPromptDone()
+ }
+ show()
+ }
+ }
+ } else {
+ onPromptDone()
+ }
+ }
+
+ /**
+ * Check if a given [Throwable] is the result of an error caused by the user cancelling the
+ * operation.
+ */
+ private fun isExplicitlyUserInitiatedError(throwable: Throwable): Boolean {
+ var cause: Throwable? = throwable
+ while (cause != null) {
+ if (cause is SSHException &&
+ cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER)
+ return true
+ cause = cause.cause
+ }
+ return false
+ }
+
+ /**
+ * Get the real root cause of a [Throwable] by traversing until known wrapping exceptions are no
+ * longer found.
+ */
+ private fun rootCauseException(throwable: Throwable): Throwable {
+ var rootCause = throwable
+ // JGit's InvalidRemoteException and TransportException hide the more helpful SSHJ exceptions.
+ // Also, SSHJ's UserAuthException about exhausting available authentication methods hides
+ // more useful exceptions.
+ while ((rootCause is org.eclipse.jgit.errors.TransportException ||
+ rootCause is org.eclipse.jgit.api.errors.TransportException ||
+ rootCause is org.eclipse.jgit.api.errors.InvalidRemoteException ||
+ (rootCause is UserAuthException &&
+ rootCause.message == "Exhausted available authentication methods"))) {
+ rootCause = rootCause.cause ?: break
+ }
+ return rootCause
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitConfigActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitConfigActivity.kt
new file mode 100644
index 00000000..4067fda9
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitConfigActivity.kt
@@ -0,0 +1,153 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.git.config
+
+import android.content.Intent
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.util.Patterns
+import android.view.MenuItem
+import androidx.core.os.postDelayed
+import androidx.lifecycle.lifecycleScope
+import com.github.ajalt.timberkt.e
+import com.github.michaelbull.result.fold
+import com.github.michaelbull.result.getOrElse
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.runCatching
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.snackbar.Snackbar
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.databinding.ActivityGitConfigBinding
+import dev.msfjarvis.aps.util.settings.GitSettings
+import dev.msfjarvis.aps.ui.git.log.GitLogActivity
+import dev.msfjarvis.aps.data.repo.PasswordRepository
+import dev.msfjarvis.aps.ui.git.base.BaseGitActivity
+import dev.msfjarvis.aps.util.extensions.viewBinding
+import kotlinx.coroutines.launch
+import org.eclipse.jgit.lib.Constants
+import org.eclipse.jgit.lib.Repository
+
+class GitConfigActivity : BaseGitActivity() {
+
+ private val binding by viewBinding(ActivityGitConfigBinding::inflate)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(binding.root)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+
+ if (GitSettings.authorName.isEmpty())
+ binding.gitUserName.requestFocus()
+ else
+ binding.gitUserName.setText(GitSettings.authorName)
+ binding.gitUserEmail.setText(GitSettings.authorEmail)
+ setupTools()
+ binding.saveButton.setOnClickListener {
+ val email = binding.gitUserEmail.text.toString().trim()
+ val name = binding.gitUserName.text.toString().trim()
+ if (!email.matches(Patterns.EMAIL_ADDRESS.toRegex())) {
+ MaterialAlertDialogBuilder(this)
+ .setMessage(getString(R.string.invalid_email_dialog_text))
+ .setPositiveButton(getString(R.string.dialog_ok), null)
+ .show()
+ } else {
+ GitSettings.authorEmail = email
+ GitSettings.authorName = name
+ Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show()
+ Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
+ }
+ }
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ android.R.id.home -> {
+ finish()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ /**
+ * Sets up the UI components of the tools section.
+ */
+ private fun setupTools() {
+ val repo = PasswordRepository.getRepository(null)
+ if (repo != null) {
+ binding.gitHeadStatus.text = headStatusMsg(repo)
+ // enable the abort button only if we're rebasing
+ val isRebasing = repo.repositoryState.isRebasing
+ binding.gitAbortRebase.isEnabled = isRebasing
+ binding.gitAbortRebase.alpha = if (isRebasing) 1.0f else 0.5f
+ }
+ binding.gitLog.setOnClickListener {
+ runCatching {
+ startActivity(Intent(this, GitLogActivity::class.java))
+ }.onFailure { ex ->
+ e(ex) { "Failed to start GitLogActivity" }
+ }
+ }
+ binding.gitAbortRebase.setOnClickListener {
+ lifecycleScope.launch {
+ launchGitOperation(GitOp.BREAK_OUT_OF_DETACHED).fold(
+ success = {
+ MaterialAlertDialogBuilder(this@GitConfigActivity).run {
+ setTitle(resources.getString(R.string.git_abort_and_push_title))
+ setMessage(resources.getString(
+ R.string.git_break_out_of_detached_success,
+ GitSettings.branch,
+ "conflicting-${GitSettings.branch}-...",
+ ))
+ setOnDismissListener { finish() }
+ setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> }
+ show()
+ }
+ },
+ failure = { err ->
+ promptOnErrorHandler(err) {
+ finish()
+ }
+ },
+ )
+ }
+ }
+ binding.gitResetToRemote.setOnClickListener {
+ lifecycleScope.launch {
+ launchGitOperation(GitOp.RESET).fold(
+ success = ::finishOnSuccessHandler,
+ failure = { err ->
+ promptOnErrorHandler(err) {
+ finish()
+ }
+ },
+ )
+ }
+ }
+ }
+
+ /**
+ * Returns a user-friendly message about the current state of HEAD.
+ *
+ * The state is recognized to be either pointing to a branch or detached.
+ */
+ private fun headStatusMsg(repo: Repository): String {
+ return runCatching {
+ val headRef = repo.getRef(Constants.HEAD)
+ if (headRef.isSymbolic) {
+ val branchName = headRef.target.name
+ val shortBranchName = Repository.shortenRefName(branchName)
+ getString(R.string.git_head_on_branch, shortBranchName)
+ } else {
+ val commitHash = headRef.objectId.abbreviate(8).name()
+ getString(R.string.git_head_detached, commitHash)
+ }
+ }.getOrElse { ex ->
+ e(ex) { "Error getting HEAD reference" }
+ getString(R.string.git_head_missing)
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitServerConfigActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitServerConfigActivity.kt
new file mode 100644
index 00000000..a0750eeb
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitServerConfigActivity.kt
@@ -0,0 +1,267 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.git.config
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.view.MenuItem
+import android.view.View
+import androidx.core.os.postDelayed
+import androidx.core.view.isVisible
+import androidx.core.widget.doOnTextChanged
+import androidx.lifecycle.lifecycleScope
+import com.github.ajalt.timberkt.e
+import com.github.michaelbull.result.fold
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.runCatching
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.snackbar.Snackbar
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.databinding.ActivityGitCloneBinding
+import dev.msfjarvis.aps.util.settings.AuthMode
+import dev.msfjarvis.aps.util.settings.GitSettings
+import dev.msfjarvis.aps.util.settings.Protocol
+import dev.msfjarvis.aps.ui.dialogs.BasicBottomSheet
+import dev.msfjarvis.aps.data.repo.PasswordRepository
+import dev.msfjarvis.aps.ui.git.base.BaseGitActivity
+import dev.msfjarvis.aps.util.extensions.snackbar
+import dev.msfjarvis.aps.util.extensions.viewBinding
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * Activity that encompasses both the initial clone as well as editing the server config for future
+ * changes.
+ */
+class GitServerConfigActivity : BaseGitActivity() {
+
+ private val binding by viewBinding(ActivityGitCloneBinding::inflate)
+
+ private lateinit var newAuthMode: AuthMode
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val isClone = intent?.extras?.getBoolean("cloning") ?: false
+ if (isClone) {
+ binding.saveButton.text = getString(R.string.clone_button)
+ }
+ setContentView(binding.root)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+
+ newAuthMode = GitSettings.authMode
+
+ binding.authModeGroup.apply {
+ when (newAuthMode) {
+ AuthMode.SshKey -> check(binding.authModeSshKey.id)
+ AuthMode.Password -> check(binding.authModePassword.id)
+ AuthMode.OpenKeychain -> check(binding.authModeOpenKeychain.id)
+ AuthMode.None -> check(View.NO_ID)
+ }
+ addOnButtonCheckedListener { _, _, _ ->
+ when (checkedButtonId) {
+ binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey
+ binding.authModeOpenKeychain.id -> newAuthMode = AuthMode.OpenKeychain
+ binding.authModePassword.id -> newAuthMode = AuthMode.Password
+ View.NO_ID -> newAuthMode = AuthMode.None
+ }
+ }
+ }
+
+ binding.serverUrl.setText(GitSettings.url.also {
+ if (it.isNullOrEmpty()) return@also
+ setAuthModes(it.startsWith("http://") || it.startsWith("https://"))
+ })
+ binding.serverBranch.setText(GitSettings.branch)
+
+ binding.serverUrl.doOnTextChanged { text, _, _, _ ->
+ if (text.isNullOrEmpty()) return@doOnTextChanged
+ setAuthModes(text.startsWith("http://") || text.startsWith("https://"))
+ }
+
+ binding.saveButton.setOnClickListener {
+ val newUrl = binding.serverUrl.text.toString().trim()
+ // If url is of type john_doe@example.org:12435/path/to/repo, then not adding `ssh://`
+ // in the beginning will cause the port to be seen as part of the path. Let users know
+ // about it and offer a quickfix.
+ if (newUrl.contains(PORT_REGEX)) {
+ if (newUrl.startsWith("https://")) {
+ BasicBottomSheet.Builder(this)
+ .setTitleRes(R.string.https_scheme_with_port_title)
+ .setMessageRes(R.string.https_scheme_with_port_message)
+ .setPositiveButtonClickListener {
+ binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/"))
+ }
+ .build()
+ .show(supportFragmentManager, "SSH_SCHEME_WARNING")
+ return@setOnClickListener
+ } else if (!newUrl.startsWith("ssh://")) {
+ BasicBottomSheet.Builder(this)
+ .setTitleRes(R.string.ssh_scheme_needed_title)
+ .setMessageRes(R.string.ssh_scheme_needed_message)
+ .setPositiveButtonClickListener {
+ @Suppress("SetTextI18n")
+ binding.serverUrl.setText("ssh://$newUrl")
+ }
+ .build()
+ .show(supportFragmentManager, "SSH_SCHEME_WARNING")
+ return@setOnClickListener
+ }
+ }
+ when (val updateResult = GitSettings.updateConnectionSettingsIfValid(
+ newAuthMode = newAuthMode,
+ newUrl = binding.serverUrl.text.toString().trim(),
+ newBranch = binding.serverBranch.text.toString().trim())) {
+ GitSettings.UpdateConnectionSettingsResult.FailedToParseUrl -> {
+ Snackbar.make(binding.root, getString(R.string.git_server_config_save_error), Snackbar.LENGTH_LONG).show()
+ }
+
+ is GitSettings.UpdateConnectionSettingsResult.MissingUsername -> {
+ when (updateResult.newProtocol) {
+ Protocol.Https ->
+ BasicBottomSheet.Builder(this)
+ .setTitleRes(R.string.ssh_scheme_needed_title)
+ .setMessageRes(R.string.git_server_config_save_missing_username_https)
+ .setPositiveButtonClickListener {
+ }
+ .build()
+ .show(supportFragmentManager, "HTTPS_MISSING_USERNAME")
+ Protocol.Ssh ->
+ BasicBottomSheet.Builder(this)
+ .setTitleRes(R.string.ssh_scheme_needed_title)
+ .setMessageRes(R.string.git_server_config_save_missing_username_ssh)
+ .setPositiveButtonClickListener {
+ }
+ .build()
+ .show(supportFragmentManager, "SSH_MISSING_USERNAME")
+ }
+ }
+ GitSettings.UpdateConnectionSettingsResult.Valid -> {
+ if (isClone && PasswordRepository.getRepository(null) == null)
+ PasswordRepository.initialize()
+ if (!isClone) {
+ Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show()
+ Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
+ } else {
+ cloneRepository()
+ }
+ }
+ is GitSettings.UpdateConnectionSettingsResult.AuthModeMismatch -> {
+ val message = getString(
+ R.string.git_server_config_save_auth_mode_mismatch,
+ updateResult.newProtocol,
+ updateResult.validModes.joinToString(", "),
+ )
+ Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show()
+ }
+ }
+ }
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ android.R.id.home -> {
+ finish()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ private fun setAuthModes(isHttps: Boolean) = with(binding) {
+ if (isHttps) {
+ authModeSshKey.isVisible = false
+ authModeOpenKeychain.isVisible = false
+ authModePassword.isVisible = true
+ if (authModeGroup.checkedButtonId != authModePassword.id)
+ authModeGroup.check(View.NO_ID)
+ } else {
+ authModeSshKey.isVisible = true
+ authModeOpenKeychain.isVisible = true
+ authModePassword.isVisible = true
+ if (authModeGroup.checkedButtonId == View.NO_ID)
+ authModeGroup.check(authModeSshKey.id)
+ }
+ }
+
+ /**
+ * Clones the repository, the directory exists, deletes it
+ */
+ private fun cloneRepository() {
+ val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory())
+ val localDirFiles = localDir.listFiles() ?: emptyArray()
+ // Warn if non-empty folder unless it's a just-initialized store that has just a .git folder
+ if (localDir.exists() && localDirFiles.isNotEmpty() &&
+ !(localDirFiles.size == 1 && localDirFiles[0].name == ".git")) {
+ MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.dialog_delete_title)
+ .setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString()))
+ .setCancelable(false)
+ .setPositiveButton(R.string.dialog_delete) { dialog, _ ->
+ runCatching {
+ lifecycleScope.launch {
+ val snackbar = snackbar(message = getString(R.string.delete_directory_progress_text), length = Snackbar.LENGTH_INDEFINITE)
+ withContext(Dispatchers.IO) {
+ localDir.deleteRecursively()
+ }
+ snackbar.dismiss()
+ launchGitOperation(GitOp.CLONE).fold(
+ success = {
+ setResult(RESULT_OK)
+ finish()
+ },
+ failure = { err ->
+ promptOnErrorHandler(err) {
+ finish()
+ }
+ }
+ )
+ }
+ }.onFailure { e ->
+ e.printStackTrace()
+ MaterialAlertDialogBuilder(this).setMessage(e.message).show()
+ }
+ dialog.cancel()
+ }
+ .setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ ->
+ dialog.cancel()
+ }
+ .show()
+ } else {
+ runCatching {
+ // Silently delete & replace the lone .git folder if it exists
+ if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") {
+ localDir.deleteRecursively()
+ }
+ }.onFailure { e ->
+ e(e)
+ MaterialAlertDialogBuilder(this).setMessage(e.message).show()
+ }
+ lifecycleScope.launch {
+ launchGitOperation(GitOp.CLONE).fold(
+ success = {
+ setResult(RESULT_OK)
+ finish()
+ },
+ failure = { promptOnErrorHandler(it) },
+ )
+ }
+ }
+ }
+
+ companion object {
+
+ private val PORT_REGEX = ":[0-9]{1,5}/".toRegex()
+
+ fun createCloneIntent(context: Context): Intent {
+ return Intent(context, GitServerConfigActivity::class.java).apply {
+ putExtra("cloning", true)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt
new file mode 100644
index 00000000..b02733fd
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.ui.git.log
+
+import android.os.Bundle
+import android.view.MenuItem
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import dev.msfjarvis.aps.databinding.ActivityGitLogBinding
+import dev.msfjarvis.aps.ui.git.base.BaseGitActivity
+import dev.msfjarvis.aps.util.extensions.viewBinding
+
+/**
+ * Displays the repository's git commits in git-log fashion.
+ *
+ * It provides basic information about each commit by way of a non-interactive RecyclerView.
+ */
+class GitLogActivity : BaseGitActivity() {
+
+ private val binding by viewBinding(ActivityGitLogBinding::inflate)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(binding.root)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ createRecyclerView()
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ android.R.id.home -> {
+ finish()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ private fun createRecyclerView() {
+ binding.gitLogRecyclerView.apply {
+ setHasFixedSize(true)
+ addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL))
+ adapter = GitLogAdapter()
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt
new file mode 100644
index 00000000..bccbe3b4
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.ui.git.log
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.github.ajalt.timberkt.e
+import dev.msfjarvis.aps.databinding.GitLogRowLayoutBinding
+import dev.msfjarvis.aps.util.git.GitCommit
+import dev.msfjarvis.aps.util.git.GitLogModel
+import java.text.DateFormat
+import java.util.Date
+
+private fun shortHash(hash: String): String {
+ return hash.substring(0 until 8)
+}
+
+private fun stringFrom(date: Date): String {
+ return DateFormat.getDateTimeInstance().format(date)
+}
+
+/**
+ * @see GitLogActivity
+ */
+class GitLogAdapter : RecyclerView.Adapter<GitLogAdapter.ViewHolder>() {
+
+ private val model = GitLogModel()
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+ val inflater = LayoutInflater.from(parent.context)
+ val binding = GitLogRowLayoutBinding.inflate(inflater, parent, false)
+ return ViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
+ val commit = model.get(position)
+ if (commit == null) {
+ e { "There is no git commit for view holder at position $position." }
+ return
+ }
+ viewHolder.bind(commit)
+ }
+
+ override fun getItemCount() = model.size
+
+ class ViewHolder(private val binding: GitLogRowLayoutBinding) : RecyclerView.ViewHolder(binding.root) {
+
+ fun bind(commit: GitCommit) = with(binding) {
+ gitLogRowMessage.text = commit.shortMessage
+ gitLogRowHash.text = shortHash(commit.hash)
+ gitLogRowTime.text = stringFrom(commit.time)
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/main/LaunchActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/main/LaunchActivity.kt
new file mode 100644
index 00000000..a7eee919
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/main/LaunchActivity.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.main
+
+import android.content.Intent
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.edit
+import dev.msfjarvis.aps.ui.crypto.DecryptActivity
+import dev.msfjarvis.aps.ui.passwords.PasswordStore
+import dev.msfjarvis.aps.util.auth.BiometricAuthenticator
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.extensions.sharedPrefs
+
+class LaunchActivity : AppCompatActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val prefs = sharedPrefs
+ if (prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH, false)) {
+ BiometricAuthenticator.authenticate(this) {
+ when (it) {
+ is BiometricAuthenticator.Result.Success -> {
+ startTargetActivity(false)
+ }
+ is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> {
+ prefs.edit { remove(PreferenceKeys.BIOMETRIC_AUTH) }
+ startTargetActivity(false)
+ }
+ is BiometricAuthenticator.Result.Failure, BiometricAuthenticator.Result.Cancelled -> {
+ finish()
+ }
+ }
+ }
+ } else {
+ startTargetActivity(true)
+ }
+ }
+
+ private fun startTargetActivity(noAuth: Boolean) {
+ val intentToStart = if (intent.action == ACTION_DECRYPT_PASS)
+ Intent(this, DecryptActivity::class.java).apply {
+ putExtra("NAME", intent.getStringExtra("NAME"))
+ putExtra("FILE_PATH", intent.getStringExtra("FILE_PATH"))
+ putExtra("REPO_PATH", intent.getStringExtra("REPO_PATH"))
+ putExtra("LAST_CHANGED_TIMESTAMP", intent.getLongExtra("LAST_CHANGED_TIMESTAMP", 0L))
+ }
+ else
+ Intent(this, PasswordStore::class.java)
+ startActivity(intentToStart)
+
+ Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (noAuth) 0L else 500L)
+ }
+
+ companion object {
+
+ const val ACTION_DECRYPT_PASS = "DECRYPT_PASS"
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/activity/OnboardingActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/activity/OnboardingActivity.kt
new file mode 100644
index 00000000..b0443447
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/activity/OnboardingActivity.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright © 2019-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.ui.onboarding.activity
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import dev.msfjarvis.aps.R
+
+class OnboardingActivity : AppCompatActivity(R.layout.activity_onboarding) {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ supportActionBar?.hide()
+ }
+
+ override fun onBackPressed() {
+ if (supportFragmentManager.backStackEntryCount == 0) {
+ finishAffinity()
+ } else {
+ super.onBackPressed()
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/CloneFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/CloneFragment.kt
new file mode 100644
index 00000000..aed0b7a3
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/CloneFragment.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright © 2019-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ *
+ */
+
+package dev.msfjarvis.aps.ui.onboarding.fragments
+
+import android.os.Bundle
+import android.view.View
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.edit
+import androidx.fragment.app.Fragment
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.databinding.FragmentCloneBinding
+import dev.msfjarvis.aps.ui.git.config.GitServerConfigActivity
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.extensions.finish
+import dev.msfjarvis.aps.util.extensions.performTransactionWithBackStack
+import dev.msfjarvis.aps.util.extensions.sharedPrefs
+import dev.msfjarvis.aps.util.extensions.viewBinding
+
+class CloneFragment : Fragment(R.layout.fragment_clone) {
+
+ private val binding by viewBinding(FragmentCloneBinding::bind)
+
+ private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
+
+ private val cloneAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == AppCompatActivity.RESULT_OK) {
+ settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }
+ finish()
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.cloneRemote.setOnClickListener {
+ cloneToHiddenDir()
+ }
+ binding.createLocal.setOnClickListener {
+ parentFragmentManager.performTransactionWithBackStack(RepoLocationFragment.newInstance())
+ }
+ }
+
+ /**
+ * Clones a remote Git repository to the app's private directory
+ */
+ private fun cloneToHiddenDir() {
+ settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, false) }
+ cloneAction.launch(GitServerConfigActivity.createCloneIntent(requireContext()))
+ }
+
+ companion object {
+
+ fun newInstance(): CloneFragment = CloneFragment()
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/KeySelectionFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/KeySelectionFragment.kt
new file mode 100644
index 00000000..5df830b1
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/KeySelectionFragment.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.ui.onboarding.fragments
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.edit
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.ui.crypto.GetKeyIdsActivity
+import dev.msfjarvis.aps.databinding.FragmentKeySelectionBinding
+import dev.msfjarvis.aps.data.repo.PasswordRepository
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.extensions.commitChange
+import dev.msfjarvis.aps.util.extensions.finish
+import dev.msfjarvis.aps.util.extensions.sharedPrefs
+import dev.msfjarvis.aps.util.extensions.viewBinding
+import java.io.File
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import me.msfjarvis.openpgpktx.util.OpenPgpApi
+
+class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) {
+
+ private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
+ private val binding by viewBinding(FragmentKeySelectionBinding::bind)
+
+ private val gpgKeySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == AppCompatActivity.RESULT_OK) {
+ result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
+ lifecycleScope.launch {
+ withContext(Dispatchers.IO) {
+ val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id")
+ gpgIdentifierFile.writeText(keyIds.joinToString("\n"))
+ }
+ settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }
+ requireActivity().commitChange(getString(
+ R.string.git_commit_gpg_id,
+ getString(R.string.app_name)
+ ))
+ }
+ }
+ } else {
+ throw IllegalStateException("Failed to initialize repository state.")
+ }
+ finish()
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.selectKey.setOnClickListener { gpgKeySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java)) }
+ }
+
+ companion object {
+
+ fun newInstance() = KeySelectionFragment()
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/RepoLocationFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/RepoLocationFragment.kt
new file mode 100644
index 00000000..08090d1b
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/RepoLocationFragment.kt
@@ -0,0 +1,192 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ *
+ */
+
+package dev.msfjarvis.aps.ui.onboarding.fragments
+
+import android.Manifest
+import android.os.Bundle
+import android.view.View
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.edit
+import androidx.fragment.app.Fragment
+import com.github.ajalt.timberkt.d
+import com.github.ajalt.timberkt.e
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.runCatching
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.ui.settings.UserPreference
+import dev.msfjarvis.aps.databinding.FragmentRepoLocationBinding
+import dev.msfjarvis.aps.data.repo.PasswordRepository
+import dev.msfjarvis.aps.util.settings.PasswordSortOrder
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.extensions.finish
+import dev.msfjarvis.aps.util.extensions.getString
+import dev.msfjarvis.aps.util.extensions.isPermissionGranted
+import dev.msfjarvis.aps.util.extensions.listFilesRecursively
+import dev.msfjarvis.aps.util.extensions.performTransactionWithBackStack
+import dev.msfjarvis.aps.util.extensions.sharedPrefs
+import dev.msfjarvis.aps.util.extensions.viewBinding
+import java.io.File
+
+class RepoLocationFragment : Fragment(R.layout.fragment_repo_location) {
+
+ private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
+ private val binding by viewBinding(FragmentRepoLocationBinding::bind)
+ private val sortOrder: PasswordSortOrder
+ get() = PasswordSortOrder.getSortOrder(settings)
+
+ private val repositoryInitAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == AppCompatActivity.RESULT_OK) {
+ initializeRepositoryInfo()
+ }
+ }
+
+ private val externalDirectorySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == AppCompatActivity.RESULT_OK) {
+ if (checkExternalDirectory()) {
+ finish()
+ } else {
+ createRepository()
+ }
+ }
+ }
+
+ private val externalDirPermGrantedAction = createPermGrantedAction {
+ externalDirectorySelectAction.launch(UserPreference.createDirectorySelectionIntent(requireContext()))
+ }
+
+ private val repositoryUsePermGrantedAction = createPermGrantedAction {
+ initializeRepositoryInfo()
+ }
+
+ private val repositoryChangePermGrantedAction = createPermGrantedAction {
+ repositoryInitAction.launch(UserPreference.createDirectorySelectionIntent(requireContext()))
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.hidden.setOnClickListener {
+ createRepoInHiddenDir()
+ }
+
+ binding.sdcard.setOnClickListener {
+ createRepoFromExternalDir()
+ }
+ }
+
+ /**
+ * Initializes an empty repository in the app's private directory
+ */
+ private fun createRepoInHiddenDir() {
+ settings.edit {
+ putBoolean(PreferenceKeys.GIT_EXTERNAL, false)
+ remove(PreferenceKeys.GIT_EXTERNAL_REPO)
+ }
+ initializeRepositoryInfo()
+ }
+
+ /**
+ * Initializes an empty repository in a selected directory if one does not already exist
+ */
+ private fun createRepoFromExternalDir() {
+ settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, true) }
+ val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
+ if (externalRepo == null) {
+ if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+ externalDirPermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ } else {
+ // Unlikely we have storage permissions without user ever selecting a directory,
+ // but let's not assume.
+ externalDirectorySelectAction.launch(UserPreference.createDirectorySelectionIntent(requireContext()))
+ }
+ } else {
+ MaterialAlertDialogBuilder(requireActivity())
+ .setTitle(resources.getString(R.string.directory_selected_title))
+ .setMessage(resources.getString(R.string.directory_selected_message, externalRepo))
+ .setPositiveButton(resources.getString(R.string.use)) { _, _ ->
+ if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+ repositoryUsePermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ } else {
+ initializeRepositoryInfo()
+ }
+ }
+ .setNegativeButton(resources.getString(R.string.change)) { _, _ ->
+ if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+ repositoryChangePermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ } else {
+ repositoryInitAction.launch(UserPreference.createDirectorySelectionIntent(requireContext()))
+ }
+ }
+ .show()
+ }
+ }
+
+ private fun checkExternalDirectory(): Boolean {
+ if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) &&
+ settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) != null) {
+ val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
+ val dir = externalRepoPath?.let { File(it) }
+ if (dir != null && // The directory could be opened
+ dir.exists() && // The directory exists
+ dir.isDirectory && // The directory, is really a directory
+ dir.listFilesRecursively().isNotEmpty() && // The directory contains files
+ // The directory contains a non-zero number of password files
+ PasswordRepository.getPasswords(dir, PasswordRepository.getRepositoryDirectory(), sortOrder).isNotEmpty()
+ ) {
+ PasswordRepository.closeRepository()
+ return true
+ }
+ }
+ return false
+ }
+
+ private fun createRepository() {
+ val localDir = PasswordRepository.getRepositoryDirectory()
+ runCatching {
+ check(localDir.exists() || localDir.mkdir()) { "Failed to create directory!" }
+ PasswordRepository.createRepository(localDir)
+ if (!PasswordRepository.isInitialized) {
+ PasswordRepository.initialize()
+ }
+ parentFragmentManager.performTransactionWithBackStack(KeySelectionFragment.newInstance())
+ }.onFailure { e ->
+ e(e)
+ if (!localDir.delete()) {
+ d { "Failed to delete local repository: $localDir" }
+ }
+ finish()
+ }
+ }
+
+ private fun initializeRepositoryInfo() {
+ val externalRepo = settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)
+ val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
+ if (externalRepo && !isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+ return
+ }
+ if (externalRepo && externalRepoPath != null) {
+ if (checkExternalDirectory()) {
+ finish()
+ return
+ }
+ }
+ createRepository()
+ }
+
+ private fun createPermGrantedAction(block: () -> Unit) =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ if (granted) {
+ block.invoke()
+ }
+ }
+
+ companion object {
+
+ fun newInstance(): RepoLocationFragment = RepoLocationFragment()
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/WelcomeFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/WelcomeFragment.kt
new file mode 100644
index 00000000..696aba17
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/WelcomeFragment.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright © 2019-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.ui.onboarding.fragments
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import androidx.annotation.Keep
+import androidx.fragment.app.Fragment
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.ui.settings.UserPreference
+import dev.msfjarvis.aps.databinding.FragmentWelcomeBinding
+import dev.msfjarvis.aps.util.extensions.performTransactionWithBackStack
+import dev.msfjarvis.aps.util.extensions.viewBinding
+
+@Keep
+@Suppress("unused")
+class WelcomeFragment : Fragment(R.layout.fragment_welcome) {
+
+ private val binding by viewBinding(FragmentWelcomeBinding::bind)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.letsGo.setOnClickListener { parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance()) }
+ binding.settingsButton.setOnClickListener { startActivity(Intent(requireContext(), UserPreference::class.java)) }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt
new file mode 100644
index 00000000..9d880de8
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt
@@ -0,0 +1,344 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.passwords
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.os.Bundle
+import android.os.Parcelable
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import android.view.animation.Animation
+import android.view.animation.AnimationUtils
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import androidx.appcompat.view.ActionMode
+import androidx.core.content.edit
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.github.michaelbull.result.fold
+import com.github.michaelbull.result.runCatching
+import com.github.michaelbull.result.onFailure
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryViewModel
+import dev.msfjarvis.aps.databinding.PasswordRecyclerViewBinding
+import dev.msfjarvis.aps.ui.git.base.BaseGitActivity
+import dev.msfjarvis.aps.ui.git.config.GitServerConfigActivity
+import dev.msfjarvis.aps.util.settings.AuthMode
+import dev.msfjarvis.aps.util.settings.GitSettings
+import dev.msfjarvis.aps.ui.util.OnOffItemAnimator
+import dev.msfjarvis.aps.ui.adapters.PasswordItemRecyclerAdapter
+import dev.msfjarvis.aps.ui.dialogs.BasicBottomSheet
+import dev.msfjarvis.aps.ui.dialogs.ItemCreationBottomSheet
+import dev.msfjarvis.aps.data.password.PasswordItem
+import dev.msfjarvis.aps.data.repo.PasswordRepository
+import dev.msfjarvis.aps.util.settings.PasswordSortOrder
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.extensions.base64
+import dev.msfjarvis.aps.util.extensions.getString
+import dev.msfjarvis.aps.util.extensions.sharedPrefs
+import dev.msfjarvis.aps.util.extensions.viewBinding
+import java.io.File
+import kotlinx.coroutines.launch
+import me.zhanghai.android.fastscroll.FastScrollerBuilder
+
+class PasswordFragment : Fragment(R.layout.password_recycler_view) {
+
+ private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
+ private lateinit var listener: OnFragmentInteractionListener
+ private lateinit var settings: SharedPreferences
+
+ private var recyclerViewStateToRestore: Parcelable? = null
+ private var actionMode: ActionMode? = null
+ private var scrollTarget: File? = null
+
+ private val model: SearchableRepositoryViewModel by activityViewModels()
+ private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
+ private val swipeResult = registerForActivityResult(StartActivityForResult()) {
+ binding.swipeRefresher.isRefreshing = false
+ requireStore().refreshPasswordList()
+ }
+
+ val currentDir: File
+ get() = model.currentDir.value!!
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ settings = requireContext().sharedPrefs
+ initializePasswordList()
+ binding.fab.setOnClickListener {
+ ItemCreationBottomSheet().show(childFragmentManager, "BOTTOM_SHEET")
+ }
+ childFragmentManager.setFragmentResultListener(ITEM_CREATION_REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
+ when (bundle.getString(ACTION_KEY)) {
+ ACTION_FOLDER -> requireStore().createFolder()
+ ACTION_PASSWORD -> requireStore().createPassword()
+ }
+ }
+ }
+
+ private fun initializePasswordList() {
+ val gitDir = File(PasswordRepository.getRepositoryDirectory(), ".git")
+ val hasGitDir = gitDir.exists() && gitDir.isDirectory && (gitDir.listFiles()?.isNotEmpty() == true)
+ binding.swipeRefresher.setOnRefreshListener {
+ if (!hasGitDir) {
+ requireStore().refreshPasswordList()
+ binding.swipeRefresher.isRefreshing = false
+ } else if (!PasswordRepository.isGitRepo()) {
+ BasicBottomSheet.Builder(requireContext())
+ .setMessageRes(R.string.clone_git_repo)
+ .setPositiveButtonClickListener(getString(R.string.clone_button)) {
+ swipeResult.launch(GitServerConfigActivity.createCloneIntent(requireContext()))
+ }
+ .build()
+ .show(requireActivity().supportFragmentManager, "NOT_A_GIT_REPO")
+ binding.swipeRefresher.isRefreshing = false
+ } else {
+ // When authentication is set to AuthMode.None then the only git operation we can
+ // run is a pull, so automatically fallback to that.
+ val operationId = when (GitSettings.authMode) {
+ AuthMode.None -> BaseGitActivity.GitOp.PULL
+ else -> BaseGitActivity.GitOp.SYNC
+ }
+ requireStore().apply {
+ lifecycleScope.launch {
+ launchGitOperation(operationId).fold(
+ success = {
+ binding.swipeRefresher.isRefreshing = false
+ refreshPasswordList()
+ },
+ failure = { err ->
+ promptOnErrorHandler(err) {
+ binding.swipeRefresher.isRefreshing = false
+ }
+ },
+ )
+ }
+ }
+ }
+ }
+
+ recyclerAdapter = PasswordItemRecyclerAdapter()
+ .onItemClicked { _, item ->
+ listener.onFragmentInteraction(item)
+ }
+ .onSelectionChanged { selection ->
+ // In order to not interfere with drag selection, we disable the SwipeRefreshLayout
+ // once an item is selected.
+ binding.swipeRefresher.isEnabled = selection.isEmpty
+
+ if (actionMode == null)
+ actionMode = requireStore().startSupportActionMode(actionModeCallback)
+ ?: return@onSelectionChanged
+
+ if (!selection.isEmpty) {
+ actionMode!!.title = resources.getQuantityString(R.plurals.delete_title, selection.size(), selection.size())
+ actionMode!!.invalidate()
+ } else {
+ actionMode!!.finish()
+ }
+ }
+ val recyclerView = binding.passRecycler
+ recyclerView.apply {
+ addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL))
+ layoutManager = LinearLayoutManager(requireContext())
+ itemAnimator = OnOffItemAnimator()
+ adapter = recyclerAdapter
+ }
+
+ FastScrollerBuilder(recyclerView).build()
+ recyclerAdapter.makeSelectable(recyclerView)
+ registerForContextMenu(recyclerView)
+
+ val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH))
+ model.navigateTo(File(path), pushPreviousLocation = false)
+ model.searchResult.observe(viewLifecycleOwner) { result ->
+ // Only run animations when the new list is filtered, i.e., the user submitted a search,
+ // and not on folder navigations since the latter leads to too many removal animations.
+ (recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered
+ recyclerAdapter.submitList(result.passwordItems) {
+ when {
+ result.isFiltered -> {
+ // When the result is filtered, we always scroll to the top since that is where
+ // the best fuzzy match appears.
+ recyclerView.scrollToPosition(0)
+ }
+ scrollTarget != null -> {
+ scrollTarget?.let {
+ recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it))
+ }
+ scrollTarget = null
+ }
+ else -> {
+ // When the result is not filtered and there is a saved scroll position for it,
+ // we try to restore it.
+ recyclerViewStateToRestore?.let {
+ recyclerView.layoutManager!!.onRestoreInstanceState(it)
+ }
+ recyclerViewStateToRestore = null
+ }
+ }
+ }
+ }
+ }
+
+ private val actionModeCallback = object : ActionMode.Callback {
+ // Called when the action mode is created; startActionMode() was called
+ override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
+ // Inflate a menu resource providing context menu items
+ mode.menuInflater.inflate(R.menu.context_pass, menu)
+ // hide the fab
+ animateFab(false)
+ return true
+ }
+
+ // Called each time the action mode is shown. Always called after onCreateActionMode, but
+ // may be called multiple times if the mode is invalidated.
+ override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
+ menu.findItem(R.id.menu_edit_password).isVisible =
+ recyclerAdapter.getSelectedItems()
+ .all { it.type == PasswordItem.TYPE_CATEGORY }
+ return true
+ }
+
+ // Called when the user selects a contextual menu item
+ override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
+ return when (item.itemId) {
+ R.id.menu_delete_password -> {
+ requireStore().deletePasswords(recyclerAdapter.getSelectedItems())
+ // Action picked, so close the CAB
+ mode.finish()
+ true
+ }
+ R.id.menu_move_password -> {
+ requireStore().movePasswords(recyclerAdapter.getSelectedItems())
+ false
+ }
+ R.id.menu_edit_password -> {
+ requireStore().renameCategory(recyclerAdapter.getSelectedItems())
+ mode.finish()
+ false
+ }
+ else -> false
+ }
+ }
+
+ // Called when the user exits the action mode
+ override fun onDestroyActionMode(mode: ActionMode) {
+ recyclerAdapter.requireSelectionTracker().clearSelection()
+ actionMode = null
+ // show the fab
+ animateFab(true)
+ }
+
+ private fun animateFab(show: Boolean) = with(binding.fab) {
+ val animation = AnimationUtils.loadAnimation(
+ context, if (show) R.anim.scale_up else R.anim.scale_down
+ )
+ animation.setAnimationListener(object : Animation.AnimationListener {
+ override fun onAnimationRepeat(animation: Animation?) {
+ }
+
+ override fun onAnimationEnd(animation: Animation?) {
+ if (!show) visibility = View.GONE
+ }
+
+ override fun onAnimationStart(animation: Animation?) {
+ if (show) visibility = View.VISIBLE
+ }
+ })
+ animate().rotationBy(if (show) -90f else 90f)
+ .setStartDelay(if (show) 100 else 0)
+ .setDuration(100)
+ .start()
+ startAnimation(animation)
+ }
+ }
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ runCatching {
+ listener = object : OnFragmentInteractionListener {
+ override fun onFragmentInteraction(item: PasswordItem) {
+ if (settings.getString(PreferenceKeys.SORT_ORDER) == PasswordSortOrder.RECENTLY_USED.name) {
+ //save the time when password was used
+ val preferences = context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
+ preferences.edit {
+ putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString())
+ }
+ }
+
+ if (item.type == PasswordItem.TYPE_CATEGORY) {
+ navigateTo(item.file)
+ } else {
+ if (requireArguments().getBoolean("matchWith", false)) {
+ requireStore().matchPasswordWithApp(item)
+ } else {
+ requireStore().decryptPassword(item)
+ }
+ }
+ }
+ }
+ }.onFailure {
+ throw ClassCastException("$context must implement OnFragmentInteractionListener")
+ }
+ }
+
+ private fun requireStore() = requireActivity() as PasswordStore
+
+ /**
+ * Returns true if the back press was handled by the [Fragment].
+ */
+ fun onBackPressedInActivity(): Boolean {
+ if (!model.canNavigateBack)
+ return false
+ // The RecyclerView state is restored when the asynchronous update operation on the
+ // adapter is completed.
+ recyclerViewStateToRestore = model.navigateBack()
+ if (!model.canNavigateBack)
+ requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(false)
+ return true
+ }
+
+ fun dismissActionMode() {
+ actionMode?.finish()
+ }
+
+ companion object {
+
+ const val ITEM_CREATION_REQUEST_KEY = "creation_key"
+ const val ACTION_KEY = "action"
+ const val ACTION_FOLDER = "folder"
+ const val ACTION_PASSWORD = "password"
+
+ fun newInstance(args: Bundle): PasswordFragment {
+ val fragment = PasswordFragment()
+ fragment.arguments = args
+ return fragment
+ }
+ }
+
+
+ fun navigateTo(file: File) {
+ requireStore().clearSearch()
+ model.navigateTo(
+ file,
+ recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState()
+ )
+ requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ }
+
+ fun scrollToOnNextRefresh(file: File) {
+ scrollTarget = file
+ }
+
+ interface OnFragmentInteractionListener {
+
+ fun onFragmentInteraction(item: PasswordItem)
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt
new file mode 100644
index 00000000..c7ed5ee9
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt
@@ -0,0 +1,697 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.passwords
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ShortcutInfo.Builder
+import android.content.pm.ShortcutManager
+import android.graphics.drawable.Icon
+import android.os.Build
+import android.os.Bundle
+import android.view.KeyEvent
+import android.view.Menu
+import android.view.MenuItem
+import android.view.MenuItem.OnActionExpandListener
+import androidx.activity.result.contract.ActivityResultContracts.RequestPermission
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import androidx.activity.viewModels
+import androidx.appcompat.widget.SearchView
+import androidx.appcompat.widget.SearchView.OnQueryTextListener
+import androidx.core.content.edit
+import androidx.core.content.getSystemService
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.commit
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.lifecycleScope
+import com.github.ajalt.timberkt.d
+import com.github.ajalt.timberkt.e
+import com.github.ajalt.timberkt.i
+import com.github.ajalt.timberkt.w
+import com.github.michaelbull.result.fold
+import com.github.michaelbull.result.getOr
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.runCatching
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.textfield.TextInputEditText
+import dev.msfjarvis.aps.ui.main.LaunchActivity
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryViewModel
+import dev.msfjarvis.aps.ui.folderselect.SelectFolderActivity
+import dev.msfjarvis.aps.ui.settings.UserPreference
+import dev.msfjarvis.aps.util.autofill.AutofillMatcher
+import dev.msfjarvis.aps.ui.crypto.BasePgpActivity.Companion.getLongName
+import dev.msfjarvis.aps.ui.crypto.DecryptActivity
+import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivity
+import dev.msfjarvis.aps.ui.git.base.BaseGitActivity
+import dev.msfjarvis.aps.util.settings.AuthMode
+import dev.msfjarvis.aps.util.settings.GitSettings
+import dev.msfjarvis.aps.ui.dialogs.BasicBottomSheet
+import dev.msfjarvis.aps.ui.dialogs.FolderCreationDialogFragment
+import dev.msfjarvis.aps.ui.onboarding.activity.OnboardingActivity
+import dev.msfjarvis.aps.data.password.PasswordItem
+import dev.msfjarvis.aps.data.repo.PasswordRepository
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.extensions.base64
+import dev.msfjarvis.aps.util.extensions.commitChange
+import dev.msfjarvis.aps.util.extensions.contains
+import dev.msfjarvis.aps.util.extensions.getString
+import dev.msfjarvis.aps.util.extensions.isInsideRepository
+import dev.msfjarvis.aps.util.extensions.isPermissionGranted
+import dev.msfjarvis.aps.util.extensions.listFilesRecursively
+import dev.msfjarvis.aps.util.extensions.requestInputFocusOnView
+import dev.msfjarvis.aps.util.extensions.sharedPrefs
+import java.io.File
+import java.lang.Character.UnicodeBlock
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.eclipse.jgit.api.Git
+
+const val PASSWORD_FRAGMENT_TAG = "PasswordsList"
+
+class PasswordStore : BaseGitActivity() {
+
+ private lateinit var searchItem: MenuItem
+ private val settings by lazy { sharedPrefs }
+
+ private val model: SearchableRepositoryViewModel by viewModels {
+ ViewModelProvider.AndroidViewModelFactory(application)
+ }
+
+ private val storagePermissionRequest = registerForActivityResult(RequestPermission()) { granted ->
+ if (granted) checkLocalRepository()
+ }
+
+ private val directorySelectAction = registerForActivityResult(StartActivityForResult()) { result ->
+ if (result.resultCode == RESULT_OK) {
+ checkLocalRepository()
+ }
+ }
+
+ private val listRefreshAction = registerForActivityResult(StartActivityForResult()) { result ->
+ if (result.resultCode == RESULT_OK) {
+ refreshPasswordList()
+ }
+ }
+
+ private val passwordMoveAction = registerForActivityResult(StartActivityForResult()) { result ->
+ val intentData = result.data ?: return@registerForActivityResult
+ val filesToMove = requireNotNull(intentData.getStringArrayExtra("Files"))
+ val target = File(requireNotNull(intentData.getStringExtra("SELECTED_FOLDER_PATH")))
+ val repositoryPath = PasswordRepository.getRepositoryDirectory().absolutePath
+ if (!target.isDirectory) {
+ e { "Tried moving passwords to a non-existing folder." }
+ return@registerForActivityResult
+ }
+
+ d { "Moving passwords to ${intentData.getStringExtra("SELECTED_FOLDER_PATH")}" }
+ d { filesToMove.joinToString(", ") }
+
+ lifecycleScope.launch(Dispatchers.IO) {
+ for (file in filesToMove) {
+ val source = File(file)
+ if (!source.exists()) {
+ e { "Tried moving something that appears non-existent." }
+ continue
+ }
+ val destinationFile = File(target.absolutePath + "/" + source.name)
+ val basename = source.nameWithoutExtension
+ val sourceLongName = getLongName(requireNotNull(source.parent), repositoryPath, basename)
+ val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename)
+ if (destinationFile.exists()) {
+ e { "Trying to move a file that already exists." }
+ withContext(Dispatchers.Main) {
+ MaterialAlertDialogBuilder(this@PasswordStore)
+ .setTitle(resources.getString(R.string.password_exists_title))
+ .setMessage(resources.getString(
+ R.string.password_exists_message,
+ destinationLongName,
+ sourceLongName)
+ )
+ .setPositiveButton(R.string.dialog_ok) { _, _ ->
+ launch(Dispatchers.IO) {
+ moveFile(source, destinationFile)
+ }
+ }
+ .setNegativeButton(R.string.dialog_cancel, null)
+ .show()
+ }
+ } else {
+ launch(Dispatchers.IO) {
+ moveFile(source, destinationFile)
+ }
+ }
+ }
+ when (filesToMove.size) {
+ 1 -> {
+ val source = File(filesToMove[0])
+ val basename = source.nameWithoutExtension
+ val sourceLongName = getLongName(requireNotNull(source.parent), repositoryPath, basename)
+ val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename)
+ withContext(Dispatchers.Main) {
+ commitChange(
+ resources.getString(R.string.git_commit_move_text, sourceLongName, destinationLongName),
+ )
+ }
+ }
+ else -> {
+ val repoDir = PasswordRepository.getRepositoryDirectory().absolutePath
+ val relativePath = getRelativePath("${target.absolutePath}/", repoDir)
+ withContext(Dispatchers.Main) {
+ commitChange(
+ resources.getString(R.string.git_commit_move_multiple_text, relativePath),
+ )
+ }
+ }
+ }
+ }
+ refreshPasswordList()
+ getPasswordFragment()?.dismissActionMode()
+ }
+
+ override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
+ // open search view on search key, or Ctr+F
+ if ((keyCode == KeyEvent.KEYCODE_SEARCH || keyCode == KeyEvent.KEYCODE_F && event.isCtrlPressed) &&
+ !searchItem.isActionViewExpanded) {
+ searchItem.expandActionView()
+ return true
+ }
+
+ // open search view on any printable character and query for it
+ val c = event.unicodeChar.toChar()
+ val printable = isPrintable(c)
+ if (printable && !searchItem.isActionViewExpanded) {
+ searchItem.expandActionView()
+ (searchItem.actionView as SearchView).setQuery(c.toString(), true)
+ return true
+ }
+ return super.onKeyDown(keyCode, event)
+ }
+
+ @SuppressLint("NewApi")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ // If user opens app with permission granted then revokes and returns,
+ // prevent attempt to create password list fragment
+ var savedInstance = savedInstanceState
+ if (savedInstanceState != null && (!settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) ||
+ !isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE))) {
+ savedInstance = null
+ }
+ super.onCreate(savedInstance)
+ setContentView(R.layout.activity_pwdstore)
+
+ model.currentDir.observe(this) { dir ->
+ val basePath = PasswordRepository.getRepositoryDirectory().absoluteFile
+ supportActionBar!!.apply {
+ if (dir != basePath)
+ title = dir.name
+ else
+ setTitle(R.string.app_name)
+ }
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ refreshPasswordList()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) {
+ hasRequiredStoragePermissions()
+ } else {
+ checkLocalRepository()
+ }
+ if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false) && ::searchItem.isInitialized) {
+ if (!searchItem.isActionViewExpanded) {
+ searchItem.expandActionView()
+ }
+ }
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu?): Boolean {
+ val menuRes = when {
+ GitSettings.authMode == AuthMode.None -> R.menu.main_menu_no_auth
+ PasswordRepository.isGitRepo() -> R.menu.main_menu_git
+ else -> R.menu.main_menu_non_git
+ }
+ menuInflater.inflate(menuRes, menu)
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onPrepareOptionsMenu(menu: Menu): Boolean {
+ // Invalidation forces onCreateOptionsMenu to be called again. This is cheap and quick so
+ // we can get by without any noticeable difference in performance.
+ invalidateOptionsMenu()
+ searchItem = menu.findItem(R.id.action_search)
+ val searchView = searchItem.actionView as SearchView
+ searchView.setOnQueryTextListener(
+ object : OnQueryTextListener {
+ override fun onQueryTextSubmit(s: String): Boolean {
+ searchView.clearFocus()
+ return true
+ }
+
+ override fun onQueryTextChange(s: String): Boolean {
+ val filter = s.trim()
+ // List the contents of the current directory if the user enters a blank
+ // search term.
+ if (filter.isEmpty())
+ model.navigateTo(
+ newDirectory = model.currentDir.value!!,
+ pushPreviousLocation = false
+ )
+ else
+ model.search(filter)
+ return true
+ }
+ })
+
+ // When using the support library, the setOnActionExpandListener() method is
+ // static and accepts the MenuItem object as an argument
+ searchItem.setOnActionExpandListener(
+ object : OnActionExpandListener {
+ override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
+ refreshPasswordList()
+ return true
+ }
+
+ override fun onMenuItemActionExpand(item: MenuItem): Boolean {
+ return true
+ }
+ })
+ if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false)) {
+ searchItem.expandActionView()
+ }
+ return super.onPrepareOptionsMenu(menu)
+ }
+
+ // Handle action bar item clicks here. The action bar will
+ // automatically handle clicks on the Home/Up button, so long
+ // as you specify a parent activity in AndroidManifest.xml.
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ val id = item.itemId
+ val initBefore = MaterialAlertDialogBuilder(this)
+ .setMessage(resources.getString(R.string.creation_dialog_text))
+ .setPositiveButton(resources.getString(R.string.dialog_ok), null)
+ when (id) {
+ R.id.user_pref -> {
+ runCatching {
+ startActivity(Intent(this, UserPreference::class.java))
+ }.onFailure { e ->
+ e.printStackTrace()
+ }
+ return true
+ }
+ R.id.git_push -> {
+ if (!PasswordRepository.isInitialized) {
+ initBefore.show()
+ return false
+ }
+ runGitOperation(GitOp.PUSH)
+ return true
+ }
+ R.id.git_pull -> {
+ if (!PasswordRepository.isInitialized) {
+ initBefore.show()
+ return false
+ }
+ runGitOperation(GitOp.PULL)
+ return true
+ }
+ R.id.git_sync -> {
+ if (!PasswordRepository.isInitialized) {
+ initBefore.show()
+ return false
+ }
+ runGitOperation(GitOp.SYNC)
+ return true
+ }
+ R.id.refresh -> {
+ refreshPasswordList()
+ return true
+ }
+ android.R.id.home -> onBackPressed()
+ else -> {
+ }
+ }
+ return super.onOptionsItemSelected(item)
+ }
+
+ override fun onBackPressed() {
+ if (getPasswordFragment()?.onBackPressedInActivity() != true)
+ super.onBackPressed()
+ }
+
+ private fun getPasswordFragment(): PasswordFragment? {
+ return supportFragmentManager.findFragmentByTag(PASSWORD_FRAGMENT_TAG) as? PasswordFragment
+ }
+
+ fun clearSearch() {
+ if (searchItem.isActionViewExpanded)
+ searchItem.collapseActionView()
+ }
+
+ private fun runGitOperation(operation: GitOp) = lifecycleScope.launch {
+ launchGitOperation(operation).fold(
+ success = { refreshPasswordList() },
+ failure = { promptOnErrorHandler(it) },
+ )
+ }
+
+ /**
+ * Validates if storage permission is granted, and requests for it if not. The return value
+ * is true if the permission has been granted.
+ */
+ private fun hasRequiredStoragePermissions(): Boolean {
+ return if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+ BasicBottomSheet.Builder(this)
+ .setMessageRes(R.string.access_sdcard_text)
+ .setPositiveButtonClickListener(getString(R.string.snackbar_action_grant)) {
+ storagePermissionRequest.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ }
+ .build()
+ .show(supportFragmentManager, "STORAGE_PERMISSION_MISSING")
+ false
+ } else {
+ checkLocalRepository()
+ true
+ }
+ }
+
+ private fun checkLocalRepository() {
+ val repo = PasswordRepository.initialize()
+ if (repo == null) {
+ directorySelectAction.launch(UserPreference.createDirectorySelectionIntent(this))
+ } else {
+ checkLocalRepository(PasswordRepository.getRepositoryDirectory())
+ }
+ }
+
+ private fun checkLocalRepository(localDir: File?) {
+ if (localDir != null && settings.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) {
+ d { "Check, dir: ${localDir.absolutePath}" }
+ // do not push the fragment if we already have it
+ if (getPasswordFragment() == null ||
+ settings.getBoolean(PreferenceKeys.REPO_CHANGED, false)) {
+ settings.edit { putBoolean(PreferenceKeys.REPO_CHANGED, false) }
+ val args = Bundle()
+ args.putString(REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath)
+
+ // if the activity was started from the autofill settings, the
+ // intent is to match a clicked pwd with app. pass this to fragment
+ if (intent.getBooleanExtra("matchWith", false)) {
+ args.putBoolean("matchWith", true)
+ }
+ supportActionBar?.apply {
+ show()
+ setDisplayHomeAsUpEnabled(false)
+ }
+ supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
+ supportFragmentManager.commit {
+ replace(R.id.main_layout, PasswordFragment.newInstance(args), PASSWORD_FRAGMENT_TAG)
+ }
+ }
+ } else {
+ startActivity(Intent(this, OnboardingActivity::class.java))
+ }
+ }
+
+ private fun getRelativePath(fullPath: String, repositoryPath: String): String {
+ return fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/")
+ }
+
+ private fun getLastChangedTimestamp(fullPath: String): Long {
+ val repoPath = PasswordRepository.getRepositoryDirectory()
+ val repository = PasswordRepository.getRepository(repoPath)
+ if (repository == null) {
+ d { "getLastChangedTimestamp: No git repository" }
+ return File(fullPath).lastModified()
+ }
+ val git = Git(repository)
+ val relativePath = getRelativePath(fullPath, repoPath.absolutePath).substring(1) // Removes leading '/'
+ return runCatching {
+ val iterator = git.log().addPath(relativePath).call().iterator()
+ if (!iterator.hasNext()) {
+ w { "getLastChangedTimestamp: No commits for file: $relativePath" }
+ return -1
+ }
+ iterator.next().commitTime.toLong() * 1000
+ }.getOr(-1)
+ }
+
+ fun decryptPassword(item: PasswordItem) {
+ val decryptIntent = Intent(this, DecryptActivity::class.java)
+ val authDecryptIntent = Intent(this, LaunchActivity::class.java)
+ for (intent in arrayOf(decryptIntent, authDecryptIntent)) {
+ intent.putExtra("NAME", item.toString())
+ intent.putExtra("FILE_PATH", item.file.absolutePath)
+ intent.putExtra("REPO_PATH", PasswordRepository.getRepositoryDirectory().absolutePath)
+ intent.putExtra("LAST_CHANGED_TIMESTAMP", getLastChangedTimestamp(item.file.absolutePath))
+ }
+ // Needs an action to be a shortcut intent
+ authDecryptIntent.action = LaunchActivity.ACTION_DECRYPT_PASS
+
+ startActivity(decryptIntent)
+
+ // Adds shortcut
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
+ val shortcutManager: ShortcutManager = getSystemService() ?: return
+ val shortcut = Builder(this, item.fullPathToParent)
+ .setShortLabel(item.toString())
+ .setLongLabel(item.fullPathToParent + item.toString())
+ .setIcon(Icon.createWithResource(this, R.drawable.ic_lock_open_24px))
+ .setIntent(authDecryptIntent)
+ .build()
+ val shortcuts = shortcutManager.dynamicShortcuts
+ if (shortcuts.size >= shortcutManager.maxShortcutCountPerActivity && shortcuts.size > 0) {
+ shortcuts.removeAt(shortcuts.size - 1)
+ shortcuts.add(0, shortcut)
+ shortcutManager.dynamicShortcuts = shortcuts
+ } else {
+ shortcutManager.addDynamicShortcuts(listOf(shortcut))
+ }
+ }
+ }
+
+ private fun validateState(): Boolean {
+ if (!PasswordRepository.isInitialized) {
+ MaterialAlertDialogBuilder(this)
+ .setMessage(resources.getString(R.string.creation_dialog_text))
+ .setPositiveButton(resources.getString(R.string.dialog_ok), null)
+ .show()
+ return false
+ }
+ return true
+ }
+
+ fun createPassword() {
+ if (!validateState()) return
+ val currentDir = currentDir
+ i { "Adding file to : ${currentDir.absolutePath}" }
+ val intent = Intent(this, PasswordCreationActivity::class.java)
+ intent.putExtra("FILE_PATH", currentDir.absolutePath)
+ intent.putExtra("REPO_PATH", PasswordRepository.getRepositoryDirectory().absolutePath)
+ listRefreshAction.launch(intent)
+ }
+
+ fun createFolder() {
+ if (!validateState()) return
+ FolderCreationDialogFragment.newInstance(currentDir.path).show(supportFragmentManager, null)
+ }
+
+ fun deletePasswords(selectedItems: List<PasswordItem>) {
+ var size = 0
+ selectedItems.forEach {
+ if (it.file.isFile)
+ size++
+ else
+ size += it.file.listFilesRecursively().size
+ }
+ if (size == 0) {
+ selectedItems.map { item -> item.file.deleteRecursively() }
+ refreshPasswordList()
+ return
+ }
+ MaterialAlertDialogBuilder(this)
+ .setMessage(resources.getQuantityString(R.plurals.delete_dialog_text, size, size))
+ .setPositiveButton(resources.getString(R.string.dialog_yes)) { _, _ ->
+ val filesToDelete = arrayListOf<File>()
+ selectedItems.forEach { item ->
+ if (item.file.isDirectory)
+ filesToDelete.addAll(item.file.listFilesRecursively())
+ else
+ filesToDelete.add(item.file)
+ }
+ selectedItems.map { item -> item.file.deleteRecursively() }
+ refreshPasswordList()
+ AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete)
+ val fmt = selectedItems.joinToString(separator = ", ") { item ->
+ item.file.toRelativeString(PasswordRepository.getRepositoryDirectory())
+ }
+ lifecycleScope.launch {
+ commitChange(
+ resources.getString(R.string.git_commit_remove_text, fmt),
+ )
+ }
+ }
+ .setNegativeButton(resources.getString(R.string.dialog_no), null)
+ .show()
+ }
+
+ fun movePasswords(values: List<PasswordItem>) {
+ val intent = Intent(this, SelectFolderActivity::class.java)
+ val fileLocations = values.map { it.file.absolutePath }.toTypedArray()
+ intent.putExtra("Files", fileLocations)
+ passwordMoveAction.launch(intent)
+ }
+
+ enum class CategoryRenameError(val resource: Int) {
+ None(0),
+ EmptyField(R.string.message_category_error_empty_field),
+ CategoryExists(R.string.message_category_error_category_exists),
+ DestinationOutsideRepo(R.string.message_error_destination_outside_repo),
+ }
+
+ /**
+ * Prompt the user with a new category name to assign,
+ * if the new category forms/leads a path (i.e. contains "/"), intermediate directories will be created
+ * and new category will be placed inside.
+ *
+ * @param oldCategory The category to change its name
+ * @param error Determines whether to show an error to the user in the alert dialog,
+ * this error may be due to the new category the user entered already exists or the field was empty or the
+ * destination path is outside the repository
+ *
+ * @see [CategoryRenameError]
+ * @see [isInsideRepository]
+ */
+ private fun renameCategory(oldCategory: PasswordItem, error: CategoryRenameError = CategoryRenameError.None) {
+ val view = layoutInflater.inflate(R.layout.folder_dialog_fragment, null)
+ val newCategoryEditText = view.findViewById<TextInputEditText>(R.id.folder_name_text)
+
+ if (error != CategoryRenameError.None) {
+ newCategoryEditText.error = getString(error.resource)
+ }
+
+ val dialog = MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.title_rename_folder)
+ .setView(view)
+ .setMessage(getString(R.string.message_rename_folder, oldCategory.name))
+ .setPositiveButton(R.string.dialog_ok) { _, _ ->
+ val newCategory = File("${oldCategory.file.parent}/${newCategoryEditText.text}")
+ when {
+ newCategoryEditText.text.isNullOrBlank() -> renameCategory(oldCategory, CategoryRenameError.EmptyField)
+ newCategory.exists() -> renameCategory(oldCategory, CategoryRenameError.CategoryExists)
+ !newCategory.isInsideRepository() -> renameCategory(oldCategory, CategoryRenameError.DestinationOutsideRepo)
+ else -> lifecycleScope.launch(Dispatchers.IO) {
+ moveFile(oldCategory.file, newCategory)
+
+ //associate the new category with the last category's timestamp in history
+ val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
+ val timestamp = preference.getString(oldCategory.file.absolutePath.base64())
+ if (timestamp != null) {
+ preference.edit {
+ remove(oldCategory.file.absolutePath.base64())
+ putString(newCategory.absolutePath.base64(), timestamp)
+ }
+ }
+
+ withContext(Dispatchers.Main) {
+ commitChange(
+ resources.getString(R.string.git_commit_move_text, oldCategory.name, newCategory.name),
+ )
+ }
+ }
+ }
+ }
+ .setNegativeButton(R.string.dialog_skip, null)
+ .create()
+
+ dialog.requestInputFocusOnView<TextInputEditText>(R.id.folder_name_text)
+ dialog.show()
+ }
+
+ fun renameCategory(categories: List<PasswordItem>) {
+ for (oldCategory in categories) {
+ renameCategory(oldCategory)
+ }
+ }
+
+ /**
+ * Refreshes the password list by re-executing the last navigation or search action, preserving
+ * the navigation stack and scroll position. If the current directory no longer exists,
+ * navigation is reset to the repository root. If the optional [target] argument is provided,
+ * it will be entered if it is a directory or scrolled into view if it is a file (both inside
+ * the current directory).
+ */
+ fun refreshPasswordList(target: File? = null) {
+ val plist = getPasswordFragment()
+ if (target?.isDirectory == true && model.currentDir.value?.contains(target) == true) {
+ plist?.navigateTo(target)
+ } else if (target?.isFile == true && model.currentDir.value?.contains(target) == true) {
+ // Creating new passwords is handled by an activity, so we will refresh in onStart.
+ plist?.scrollToOnNextRefresh(target)
+ } else if (model.currentDir.value?.isDirectory == true) {
+ model.forceRefresh()
+ } else {
+ model.reset()
+ supportActionBar!!.setDisplayHomeAsUpEnabled(false)
+ }
+ }
+
+ private val currentDir: File
+ get() = getPasswordFragment()?.currentDir ?: PasswordRepository.getRepositoryDirectory()
+
+ private suspend fun moveFile(source: File, destinationFile: File) {
+ val sourceDestinationMap = if (source.isDirectory) {
+ destinationFile.mkdirs()
+ // Recursively list all files (not directories) below `source`, then
+ // obtain the corresponding target file by resolving the relative path
+ // starting at the destination folder.
+ source.listFilesRecursively().associateWith { destinationFile.resolve(it.relativeTo(source)) }
+ } else {
+ mapOf(source to destinationFile)
+ }
+ if (!source.renameTo(destinationFile)) {
+ e { "Something went wrong while moving $source to $destinationFile." }
+ withContext(Dispatchers.Main) {
+ MaterialAlertDialogBuilder(this@PasswordStore)
+ .setTitle(R.string.password_move_error_title)
+ .setMessage(getString(R.string.password_move_error_message, source, destinationFile))
+ .setCancelable(true)
+ .setPositiveButton(android.R.string.ok, null)
+ .show()
+ }
+ } else {
+ AutofillMatcher.updateMatches(this, sourceDestinationMap)
+ }
+ }
+
+ fun matchPasswordWithApp(item: PasswordItem) {
+ val path = item.file
+ .absolutePath
+ .replace(PasswordRepository.getRepositoryDirectory().toString() + "/", "")
+ .replace(".gpg", "")
+ val data = Intent()
+ data.putExtra("path", path)
+ setResult(RESULT_OK, data)
+ finish()
+ }
+
+ companion object {
+
+ const val REQUEST_ARG_PATH = "PATH"
+ private fun isPrintable(c: Char): Boolean {
+ val block = UnicodeBlock.of(c)
+ return (!Character.isISOControl(c) &&
+ block != null && block !== UnicodeBlock.SPECIALS)
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/proxy/ProxySelectorActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/proxy/ProxySelectorActivity.kt
new file mode 100644
index 00000000..7154f217
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/proxy/ProxySelectorActivity.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.ui.proxy
+
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.util.Patterns
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.edit
+import androidx.core.os.postDelayed
+import androidx.core.widget.doOnTextChanged
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.databinding.ActivityProxySelectorBinding
+import dev.msfjarvis.aps.util.settings.GitSettings
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.proxy.ProxyUtils
+import dev.msfjarvis.aps.util.extensions.getEncryptedProxyPrefs
+import dev.msfjarvis.aps.util.extensions.getString
+import dev.msfjarvis.aps.util.extensions.viewBinding
+
+private val IP_ADDRESS_REGEX = Patterns.IP_ADDRESS.toRegex()
+private val WEB_ADDRESS_REGEX = Patterns.WEB_URL.toRegex()
+
+class ProxySelectorActivity : AppCompatActivity() {
+
+ private val binding by viewBinding(ActivityProxySelectorBinding::inflate)
+ private val proxyPrefs by lazy(LazyThreadSafetyMode.NONE) { applicationContext.getEncryptedProxyPrefs() }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(binding.root)
+ with(binding) {
+ proxyHost.setText(proxyPrefs.getString(PreferenceKeys.PROXY_HOST))
+ proxyUser.setText(proxyPrefs.getString(PreferenceKeys.PROXY_USERNAME))
+ proxyPrefs.getInt(PreferenceKeys.PROXY_PORT, -1).takeIf { it != -1 }?.let {
+ proxyPort.setText("$it")
+ }
+ proxyPassword.setText(proxyPrefs.getString(PreferenceKeys.PROXY_PASSWORD))
+ save.setOnClickListener { saveSettings() }
+ proxyHost.doOnTextChanged { text, _, _, _ ->
+ if (text != null) {
+ proxyHost.error = if (text.matches(IP_ADDRESS_REGEX) || text.matches(WEB_ADDRESS_REGEX)) {
+ null
+ } else {
+ getString(R.string.invalid_proxy_url)
+ }
+ }
+ }
+ }
+
+ }
+
+ private fun saveSettings() {
+ proxyPrefs.edit {
+ binding.proxyHost.text?.toString()?.takeIf { it.isNotEmpty() }.let {
+ GitSettings.proxyHost = it
+ }
+ binding.proxyUser.text?.toString()?.takeIf { it.isNotEmpty() }.let {
+ GitSettings.proxyUsername = it
+ }
+ binding.proxyPort.text?.toString()?.takeIf { it.isNotEmpty() }?.let {
+ GitSettings.proxyPort = it.toInt()
+ }
+ binding.proxyPassword.text?.toString()?.takeIf { it.isNotEmpty() }.let {
+ GitSettings.proxyPassword = it
+ }
+ }
+ ProxyUtils.setDefaultProxy()
+ Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/UserPreference.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/UserPreference.kt
new file mode 100644
index 00000000..df51562f
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/settings/UserPreference.kt
@@ -0,0 +1,676 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.settings
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Intent
+import android.content.SharedPreferences
+import android.content.pm.ShortcutManager
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.os.Environment
+import android.provider.DocumentsContract
+import android.provider.Settings
+import android.text.TextUtils
+import android.view.MenuItem
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
+import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree
+import androidx.annotation.RequiresApi
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.widget.AppCompatTextView
+import androidx.core.content.edit
+import androidx.core.content.getSystemService
+import androidx.documentfile.provider.DocumentFile
+import androidx.preference.CheckBoxPreference
+import androidx.preference.EditTextPreference
+import androidx.preference.ListPreference
+import androidx.preference.Preference
+import androidx.preference.PreferenceFragmentCompat
+import androidx.preference.SwitchPreferenceCompat
+import com.github.ajalt.timberkt.Timber.tag
+import com.github.ajalt.timberkt.d
+import com.github.ajalt.timberkt.w
+import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel
+import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel
+import com.github.michaelbull.result.getOr
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.runCatching
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import dev.msfjarvis.aps.BuildConfig
+import dev.msfjarvis.aps.util.services.PasswordExportService
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.ui.crypto.BasePgpActivity
+import dev.msfjarvis.aps.ui.git.config.GitConfigActivity
+import dev.msfjarvis.aps.ui.git.config.GitServerConfigActivity
+import dev.msfjarvis.aps.util.git.sshj.SshKey
+import dev.msfjarvis.aps.util.pwgenxkpwd.XkpwdDictionary
+import dev.msfjarvis.aps.ui.sshkeygen.ShowSshKeyFragment
+import dev.msfjarvis.aps.ui.sshkeygen.SshKeyGenActivity
+import dev.msfjarvis.aps.ui.proxy.ProxySelectorActivity
+import dev.msfjarvis.aps.util.auth.BiometricAuthenticator
+import dev.msfjarvis.aps.data.repo.PasswordRepository
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.extensions.autofillManager
+import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs
+import dev.msfjarvis.aps.util.extensions.getString
+import dev.msfjarvis.aps.util.extensions.sharedPrefs
+import java.io.File
+
+typealias ClickListener = Preference.OnPreferenceClickListener
+typealias ChangeListener = Preference.OnPreferenceChangeListener
+
+class UserPreference : AppCompatActivity() {
+
+ private lateinit var prefsFragment: PrefsFragment
+ private var fromIntent = false
+
+ @Suppress("DEPRECATION")
+ private val directorySelectAction = registerForActivityResult(OpenDocumentTree()) { uri: Uri? ->
+ if (uri == null) return@registerForActivityResult
+
+ tag(TAG).d { "Selected repository URI is $uri" }
+ // TODO: This is fragile. Workaround until PasswordItem is backed by DocumentFile
+ val docId = DocumentsContract.getTreeDocumentId(uri)
+ val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
+ val path = if (split.size > 1) split[1] else split[0]
+ val repoPath = "${Environment.getExternalStorageDirectory()}/$path"
+ val prefs = sharedPrefs
+
+ tag(TAG).d { "Selected repository path is $repoPath" }
+
+ if (Environment.getExternalStorageDirectory().path == repoPath) {
+ MaterialAlertDialogBuilder(this)
+ .setTitle(getString(R.string.sdcard_root_warning_title))
+ .setMessage(getString(R.string.sdcard_root_warning_message))
+ .setPositiveButton("Remove everything") { _, _ ->
+ prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, uri.path) }
+ }
+ .setNegativeButton(R.string.dialog_cancel, null)
+ .show()
+ }
+ prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, repoPath) }
+ if (fromIntent) {
+ setResult(RESULT_OK)
+ finish()
+ }
+
+ }
+
+ private val sshKeyImportAction = registerForActivityResult(OpenDocument()) { uri: Uri? ->
+ if (uri == null) return@registerForActivityResult
+ runCatching {
+ SshKey.import(uri)
+
+ Toast.makeText(this, resources.getString(R.string.ssh_key_success_dialog_title), Toast.LENGTH_LONG).show()
+ setResult(RESULT_OK)
+ finish()
+ }.onFailure { e ->
+ MaterialAlertDialogBuilder(this)
+ .setTitle(resources.getString(R.string.ssh_key_error_dialog_title))
+ .setMessage(e.message)
+ .setPositiveButton(resources.getString(R.string.dialog_ok), null)
+ .show()
+ }
+ }
+
+ private val storeExportAction = registerForActivityResult(object : OpenDocumentTree() {
+ override fun createIntent(context: Context, input: Uri?): Intent {
+ return super.createIntent(context, input).apply {
+ flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
+ Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
+ Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
+ }
+ }
+ }) { uri: Uri? ->
+ if (uri == null) return@registerForActivityResult
+ val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri)
+
+ if (targetDirectory != null) {
+ val service = Intent(applicationContext, PasswordExportService::class.java).apply {
+ action = PasswordExportService.ACTION_EXPORT_PASSWORD
+ putExtra("uri", uri)
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ startForegroundService(service)
+ } else {
+ startService(service)
+ }
+ }
+ }
+
+ private val storeCustomXkpwdDictionaryAction = registerForActivityResult(OpenDocument()) { uri ->
+ if (uri == null) return@registerForActivityResult
+
+ Toast.makeText(
+ this,
+ this.resources.getString(R.string.xkpwgen_custom_dict_imported, uri.path),
+ Toast.LENGTH_SHORT
+ ).show()
+
+ sharedPrefs.edit { putString(PreferenceKeys.PREF_KEY_CUSTOM_DICT, uri.toString()) }
+
+ val customDictPref = prefsFragment.findPreference<Preference>(PreferenceKeys.PREF_KEY_CUSTOM_DICT)
+ setCustomDictSummary(customDictPref, uri)
+ // copy user selected file to internal storage
+ val inputStream = contentResolver.openInputStream(uri)
+ val customDictFile = File(filesDir.toString(), XkpwdDictionary.XKPWD_CUSTOM_DICT_FILE).outputStream()
+ inputStream?.copyTo(customDictFile, 1024)
+ inputStream?.close()
+ customDictFile.close()
+
+ setResult(RESULT_OK)
+ }
+
+ class PrefsFragment : PreferenceFragmentCompat() {
+
+ private var autoFillEnablePreference: SwitchPreferenceCompat? = null
+ private var clearSavedPassPreference: Preference? = null
+ private var viewSshKeyPreference: Preference? = null
+ private lateinit var oreoAutofillDependencies: List<Preference>
+ private lateinit var prefsActivity: UserPreference
+ private lateinit var sharedPreferences: SharedPreferences
+ private lateinit var encryptedPreferences: SharedPreferences
+
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ prefsActivity = requireActivity() as UserPreference
+ val context = requireContext()
+ sharedPreferences = preferenceManager.sharedPreferences
+ encryptedPreferences = requireActivity().getEncryptedGitPrefs()
+
+ addPreferencesFromResource(R.xml.preference)
+
+ // Git preferences
+ val gitServerPreference = findPreference<Preference>(PreferenceKeys.GIT_SERVER_INFO)
+ val openkeystoreIdPreference = findPreference<Preference>(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID)
+ val gitConfigPreference = findPreference<Preference>(PreferenceKeys.GIT_CONFIG)
+ val sshKeyPreference = findPreference<Preference>(PreferenceKeys.SSH_KEY)
+ val sshKeygenPreference = findPreference<Preference>(PreferenceKeys.SSH_KEYGEN)
+ viewSshKeyPreference = findPreference(PreferenceKeys.SSH_SEE_KEY)
+ clearSavedPassPreference = findPreference(PreferenceKeys.CLEAR_SAVED_PASS)
+ val deleteRepoPreference = findPreference<Preference>(PreferenceKeys.GIT_DELETE_REPO)
+ val externalGitRepositoryPreference = findPreference<Preference>(PreferenceKeys.GIT_EXTERNAL)
+ val selectExternalGitRepositoryPreference = findPreference<Preference>(PreferenceKeys.PREF_SELECT_EXTERNAL)
+
+ if (!PasswordRepository.isGitRepo()) {
+ listOfNotNull(
+ gitServerPreference,
+ gitConfigPreference,
+ sshKeyPreference,
+ viewSshKeyPreference,
+ clearSavedPassPreference,
+ ).forEach {
+ it.parent?.removePreference(it)
+ }
+ }
+
+ // General preferences
+ val showTimePreference = findPreference<Preference>(PreferenceKeys.GENERAL_SHOW_TIME)
+ val clearClipboard20xPreference = findPreference<CheckBoxPreference>(PreferenceKeys.CLEAR_CLIPBOARD_20X)
+
+ // Autofill preferences
+ autoFillEnablePreference = findPreference(PreferenceKeys.AUTOFILL_ENABLE)
+ val oreoAutofillDirectoryStructurePreference = findPreference<ListPreference>(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE)
+ val oreoAutofillDefaultUsername = findPreference<EditTextPreference>(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME)
+ val oreoAutofillCustomPublixSuffixes = findPreference<EditTextPreference>(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES)
+ oreoAutofillDependencies = listOfNotNull(
+ oreoAutofillDirectoryStructurePreference,
+ oreoAutofillDefaultUsername,
+ oreoAutofillCustomPublixSuffixes,
+ )
+ oreoAutofillCustomPublixSuffixes?.apply {
+ setOnBindEditTextListener {
+ it.isSingleLine = false
+ it.setHint(R.string.preference_custom_public_suffixes_hint)
+ }
+ }
+
+ // Misc preferences
+ val appVersionPreference = findPreference<Preference>(PreferenceKeys.APP_VERSION)
+
+ selectExternalGitRepositoryPreference?.summary = sharedPreferences.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
+ ?: getString(R.string.no_repo_selected)
+ deleteRepoPreference?.isVisible = !sharedPreferences.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)
+ clearClipboard20xPreference?.isVisible = sharedPreferences.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toInt() != 0
+ openkeystoreIdPreference?.isVisible = sharedPreferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty()
+ ?: false
+
+ updateAutofillSettings()
+ updateClearSavedPassphrasePrefs()
+
+ appVersionPreference?.summary = "Version: ${BuildConfig.VERSION_NAME}"
+
+ sshKeyPreference?.onPreferenceClickListener = ClickListener {
+ prefsActivity.getSshKey()
+ true
+ }
+
+ sshKeygenPreference?.onPreferenceClickListener = ClickListener {
+ prefsActivity.makeSshKey(true)
+ true
+ }
+
+ viewSshKeyPreference?.onPreferenceClickListener = ClickListener {
+ val df = ShowSshKeyFragment()
+ df.show(parentFragmentManager, "public_key")
+ true
+ }
+
+ clearSavedPassPreference?.onPreferenceClickListener = ClickListener {
+ encryptedPreferences.edit {
+ if (encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD) != null)
+ remove(PreferenceKeys.HTTPS_PASSWORD)
+ else if (encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) != null)
+ remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE)
+ }
+ updateClearSavedPassphrasePrefs()
+ true
+ }
+
+ openkeystoreIdPreference?.onPreferenceClickListener = ClickListener {
+ sharedPreferences.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) }
+ it.isVisible = false
+ true
+ }
+
+ gitServerPreference?.onPreferenceClickListener = ClickListener {
+ startActivity(Intent(prefsActivity, GitServerConfigActivity::class.java))
+ true
+ }
+
+ gitConfigPreference?.onPreferenceClickListener = ClickListener {
+ startActivity(Intent(prefsActivity, GitConfigActivity::class.java))
+ true
+ }
+
+ deleteRepoPreference?.onPreferenceClickListener = ClickListener {
+ val repoDir = PasswordRepository.getRepositoryDirectory()
+ MaterialAlertDialogBuilder(prefsActivity)
+ .setTitle(R.string.pref_dialog_delete_title)
+ .setMessage(resources.getString(R.string.dialog_delete_msg, repoDir))
+ .setCancelable(false)
+ .setPositiveButton(R.string.dialog_delete) { dialogInterface, _ ->
+ runCatching {
+ PasswordRepository.getRepositoryDirectory().deleteRecursively()
+ PasswordRepository.closeRepository()
+ }.onFailure {
+ // TODO Handle the different cases of exceptions
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
+ requireContext().getSystemService<ShortcutManager>()?.apply {
+ removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
+ }
+ }
+ sharedPreferences.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) }
+ dialogInterface.cancel()
+ prefsActivity.finish()
+ }
+ .setNegativeButton(R.string.dialog_do_not_delete) { dialogInterface, _ -> run { dialogInterface.cancel() } }
+ .show()
+
+ true
+ }
+
+ selectExternalGitRepositoryPreference?.summary =
+ sharedPreferences.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
+ ?: context.getString(R.string.no_repo_selected)
+ selectExternalGitRepositoryPreference?.onPreferenceClickListener = ClickListener {
+ prefsActivity.selectExternalGitRepository()
+ true
+ }
+
+ val resetRepo = Preference.OnPreferenceChangeListener { _, o ->
+ deleteRepoPreference?.isVisible = !(o as Boolean)
+ PasswordRepository.closeRepository()
+ sharedPreferences.edit { putBoolean(PreferenceKeys.REPO_CHANGED, true) }
+ true
+ }
+
+ selectExternalGitRepositoryPreference?.onPreferenceChangeListener = resetRepo
+ externalGitRepositoryPreference?.onPreferenceChangeListener = resetRepo
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ autoFillEnablePreference?.onPreferenceClickListener = ClickListener {
+ onEnableAutofillClick()
+ true
+ }
+ }
+
+ findPreference<Preference>(PreferenceKeys.EXPORT_PASSWORDS)?.apply {
+ isVisible = sharedPreferences.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)
+ onPreferenceClickListener = Preference.OnPreferenceClickListener {
+ prefsActivity.exportPasswords()
+ true
+ }
+ }
+
+ showTimePreference?.onPreferenceChangeListener = ChangeListener { _, newValue: Any? ->
+ runCatching {
+ val isEnabled = newValue.toString().toInt() != 0
+ clearClipboard20xPreference?.isVisible = isEnabled
+ true
+ }.getOr(false)
+ }
+
+ showTimePreference?.summaryProvider = Preference.SummaryProvider<Preference> {
+ getString(R.string.pref_clipboard_timeout_summary, sharedPreferences.getString
+ (PreferenceKeys.GENERAL_SHOW_TIME, "45"))
+ }
+
+ findPreference<CheckBoxPreference>(PreferenceKeys.ENABLE_DEBUG_LOGGING)?.isVisible = !BuildConfig.ENABLE_DEBUG_FEATURES
+
+ findPreference<CheckBoxPreference>(PreferenceKeys.BIOMETRIC_AUTH)?.apply {
+ val canAuthenticate = BiometricAuthenticator.canAuthenticate(prefsActivity)
+
+ if (!canAuthenticate) {
+ isEnabled = false
+ isChecked = false
+ summary = getString(R.string.biometric_auth_summary_error)
+ } else {
+ setOnPreferenceClickListener {
+ isEnabled = false
+ sharedPreferences.edit {
+ val checked = isChecked
+ BiometricAuthenticator.authenticate(requireActivity()) { result ->
+ when (result) {
+ is BiometricAuthenticator.Result.Success -> {
+ // Apply the changes
+ putBoolean(PreferenceKeys.BIOMETRIC_AUTH, checked)
+ isEnabled = true
+ }
+ else -> {
+ // If any error occurs, revert back to the previous state. This
+ // catch-all clause includes the cancellation case.
+ putBoolean(PreferenceKeys.BIOMETRIC_AUTH, !checked)
+ isChecked = !checked
+ isEnabled = true
+ }
+ }
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
+ requireContext().getSystemService<ShortcutManager>()?.apply {
+ removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
+ }
+ }
+ }
+ true
+ }
+ }
+ }
+
+ findPreference<Preference>(PreferenceKeys.PROXY_SETTINGS)?.onPreferenceClickListener = ClickListener {
+ startActivity(Intent(requireContext(), ProxySelectorActivity::class.java))
+ true
+ }
+
+ val prefCustomXkpwdDictionary = findPreference<Preference>(PreferenceKeys.PREF_KEY_CUSTOM_DICT)
+ prefCustomXkpwdDictionary?.onPreferenceClickListener = ClickListener {
+ prefsActivity.storeCustomDictionaryPath()
+ true
+ }
+ val dictUri = sharedPreferences.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT) ?: ""
+
+ if (!TextUtils.isEmpty(dictUri)) {
+ setCustomDictSummary(prefCustomXkpwdDictionary, Uri.parse(dictUri))
+ }
+
+ val prefIsCustomDict = findPreference<CheckBoxPreference>(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT)
+ val prefCustomDictPicker = findPreference<Preference>(PreferenceKeys.PREF_KEY_CUSTOM_DICT)
+ val prefPwgenType = findPreference<ListPreference>(PreferenceKeys.PREF_KEY_PWGEN_TYPE)
+ updateXkPasswdPrefsVisibility(prefPwgenType?.value, prefIsCustomDict, prefCustomDictPicker)
+
+ prefPwgenType?.onPreferenceChangeListener = ChangeListener { _, newValue ->
+ updateXkPasswdPrefsVisibility(newValue, prefIsCustomDict, prefCustomDictPicker)
+ true
+ }
+
+ prefIsCustomDict?.onPreferenceChangeListener = ChangeListener { _, newValue ->
+ if (!(newValue as Boolean)) {
+ val customDictFile = File(context.filesDir, XkpwdDictionary.XKPWD_CUSTOM_DICT_FILE)
+ if (customDictFile.exists() && !customDictFile.delete()) {
+ w { "Failed to delete custom XkPassword dictionary: $customDictFile" }
+ }
+ prefCustomDictPicker?.setSummary(R.string.xkpwgen_pref_custom_dict_picker_summary)
+ }
+ true
+ }
+ }
+
+ private fun updateXkPasswdPrefsVisibility(newValue: Any?, prefIsCustomDict: CheckBoxPreference?, prefCustomDictPicker: Preference?) {
+ when (newValue as String) {
+ BasePgpActivity.KEY_PWGEN_TYPE_CLASSIC -> {
+ prefIsCustomDict?.isVisible = false
+ prefCustomDictPicker?.isVisible = false
+ }
+ BasePgpActivity.KEY_PWGEN_TYPE_XKPASSWD -> {
+ prefIsCustomDict?.isVisible = true
+ prefCustomDictPicker?.isVisible = true
+ }
+ }
+ }
+
+ private fun updateAutofillSettings() {
+ val isAutofillServiceEnabled = prefsActivity.isAutofillServiceEnabled
+ val isAutofillSupported = prefsActivity.isAutofillServiceSupported
+ if (!isAutofillSupported) {
+ autoFillEnablePreference?.isVisible = false
+ } else {
+ autoFillEnablePreference?.isChecked = isAutofillServiceEnabled
+ }
+ oreoAutofillDependencies.forEach {
+ it.isVisible = isAutofillServiceEnabled
+ }
+ }
+
+ private fun updateClearSavedPassphrasePrefs() {
+ clearSavedPassPreference?.apply {
+ val sshPass = encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE)
+ val httpsPass = encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD)
+ if (sshPass == null && httpsPass == null) {
+ isVisible = false
+ return@apply
+ }
+ title = when {
+ httpsPass != null -> getString(R.string.clear_saved_passphrase_https)
+ sshPass != null -> getString(R.string.clear_saved_passphrase_ssh)
+ else -> null
+ }
+ isVisible = true
+ }
+ }
+
+ private fun updateViewSshPubkeyPref() {
+ viewSshKeyPreference?.isVisible = SshKey.canShowSshPublicKey
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun onEnableAutofillClick() {
+ if (prefsActivity.isAutofillServiceEnabled) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
+ prefsActivity.autofillManager!!.disableAutofillServices()
+ else
+ throw IllegalStateException("isAutofillServiceEnabled == true, but Build.VERSION.SDK_INT < Build.VERSION_CODES.O")
+ } else {
+ MaterialAlertDialogBuilder(prefsActivity).run {
+ setTitle(R.string.pref_autofill_enable_title)
+ @SuppressLint("InflateParams")
+ val layout =
+ layoutInflater.inflate(R.layout.oreo_autofill_instructions, null)
+ val supportedBrowsersTextView =
+ layout.findViewById<AppCompatTextView>(R.id.supportedBrowsers)
+ supportedBrowsersTextView.text =
+ getInstalledBrowsersWithAutofillSupportLevel(context).joinToString(
+ separator = "\n"
+ ) {
+ val appLabel = it.first
+ val supportDescription = when (it.second) {
+ BrowserAutofillSupportLevel.None -> getString(R.string.oreo_autofill_no_support)
+ BrowserAutofillSupportLevel.FlakyFill -> getString(R.string.oreo_autofill_flaky_fill_support)
+ BrowserAutofillSupportLevel.PasswordFill -> getString(R.string.oreo_autofill_password_fill_support)
+ BrowserAutofillSupportLevel.GeneralFill -> getString(R.string.oreo_autofill_general_fill_support)
+ BrowserAutofillSupportLevel.GeneralFillAndSave -> getString(R.string.oreo_autofill_general_fill_and_save_support)
+ }
+ "$appLabel: $supportDescription"
+ }
+ setView(layout)
+ setPositiveButton(R.string.dialog_ok) { _, _ ->
+ val intent = Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply {
+ data = Uri.parse("package:${BuildConfig.APPLICATION_ID}")
+ }
+ startActivity(intent)
+ }
+ setNegativeButton(R.string.dialog_cancel, null)
+ setOnDismissListener { updateAutofillSettings() }
+ show()
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ updateAutofillSettings()
+ updateClearSavedPassphrasePrefs()
+ updateViewSshPubkeyPref()
+ }
+ }
+
+ override fun onBackPressed() {
+ super.onBackPressed()
+ setResult(RESULT_OK)
+ finish()
+ }
+
+ public override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ when (intent?.getStringExtra("operation")) {
+ "get_ssh_key" -> getSshKey()
+ "make_ssh_key" -> makeSshKey(false)
+ "git_external" -> {
+ fromIntent = true
+ selectExternalGitRepository()
+ }
+ }
+ prefsFragment = PrefsFragment()
+
+ supportFragmentManager
+ .beginTransaction()
+ .replace(android.R.id.content, prefsFragment)
+ .commit()
+
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ }
+
+ @Suppress("Deprecation") // for Environment.getExternalStorageDirectory()
+ fun selectExternalGitRepository() {
+ MaterialAlertDialogBuilder(this)
+ .setTitle(this.resources.getString(R.string.external_repository_dialog_title))
+ .setMessage(this.resources.getString(R.string.external_repository_dialog_text))
+ .setPositiveButton(R.string.dialog_ok) { _, _ ->
+ directorySelectAction.launch(null)
+ }
+ .setNegativeButton(R.string.dialog_cancel, null)
+ .show()
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ // Handle action bar item clicks here. The action bar will
+ // automatically handle clicks on the Home/Up button, so long
+ // as you specify a parent activity in AndroidManifest.xml.
+ return when (item.itemId) {
+ android.R.id.home -> {
+ setResult(RESULT_OK)
+ finish()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ private fun importSshKey() {
+ sshKeyImportAction.launch(arrayOf("*/*"))
+ }
+
+ /**
+ * Opens a file explorer to import the private key
+ */
+ private fun getSshKey() {
+ if (SshKey.exists) {
+ MaterialAlertDialogBuilder(this).run {
+ setTitle(R.string.ssh_keygen_existing_title)
+ setMessage(R.string.ssh_keygen_existing_message)
+ setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ ->
+ importSshKey()
+ }
+ setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> }
+ show()
+ }
+ } else {
+ importSshKey()
+ }
+ }
+
+ /**
+ * Exports the passwords
+ */
+ private fun exportPasswords() {
+ storeExportAction.launch(null)
+ }
+
+ /**
+ * Opens a key generator to generate a public/private key pair
+ */
+ fun makeSshKey(fromPreferences: Boolean) {
+ val intent = Intent(applicationContext, SshKeyGenActivity::class.java)
+ startActivity(intent)
+ if (!fromPreferences) {
+ setResult(RESULT_OK)
+ finish()
+ }
+ }
+
+ /**
+ * Pick custom xkpwd dictionary from sdcard
+ */
+ private fun storeCustomDictionaryPath() {
+ storeCustomXkpwdDictionaryAction.launch(arrayOf("*/*"))
+ }
+
+ private val isAutofillServiceSupported: Boolean
+ get() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
+ return autofillManager?.isAutofillSupported != null
+ }
+
+ private val isAutofillServiceEnabled: Boolean
+ get() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
+ return autofillManager?.hasEnabledAutofillServices() == true
+ }
+
+ companion object {
+
+ private const val TAG = "UserPreference"
+
+ fun createDirectorySelectionIntent(context: Context): Intent {
+ return Intent(context, UserPreference::class.java).run {
+ putExtra("operation", "git_external")
+ }
+ }
+
+ /**
+ * Set custom dictionary summary
+ */
+ @JvmStatic
+ private fun setCustomDictSummary(customDictPref: Preference?, uri: Uri) {
+ val fileName = uri.path?.substring(uri.path?.lastIndexOf(":")!! + 1)
+ customDictPref?.summary = "Selected dictionary: $fileName"
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/ShowSshKeyFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/ShowSshKeyFragment.kt
new file mode 100644
index 00000000..0486b452
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/ShowSshKeyFragment.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.sshkeygen
+
+import android.app.Dialog
+import android.content.Intent
+import android.os.Bundle
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.util.git.sshj.SshKey
+
+class ShowSshKeyFragment : DialogFragment() {
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val activity = requireActivity()
+ val publicKey = SshKey.sshPublicKey
+ return MaterialAlertDialogBuilder(requireActivity()).run {
+ setMessage(getString(R.string.ssh_keygen_message, publicKey))
+ setTitle(R.string.your_public_key)
+ setNegativeButton(R.string.ssh_keygen_later) { _, _ ->
+ (activity as? SshKeyGenActivity)?.finish()
+ }
+ setPositiveButton(R.string.ssh_keygen_share) { _, _ ->
+ val sendIntent = Intent().apply {
+ action = Intent.ACTION_SEND
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, publicKey)
+ }
+ startActivity(Intent.createChooser(sendIntent, null))
+ (activity as? SshKeyGenActivity)?.finish()
+ }
+ create()
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyGenActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyGenActivity.kt
new file mode 100644
index 00000000..39819988
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyGenActivity.kt
@@ -0,0 +1,164 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.sshkeygen
+
+import android.os.Bundle
+import android.security.keystore.UserNotAuthenticatedException
+import android.view.MenuItem
+import android.view.View
+import android.view.inputmethod.InputMethodManager
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.edit
+import androidx.core.content.getSystemService
+import androidx.lifecycle.lifecycleScope
+import com.github.michaelbull.result.fold
+import com.github.michaelbull.result.runCatching
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.databinding.ActivitySshKeygenBinding
+import dev.msfjarvis.aps.util.git.sshj.SshKey
+import dev.msfjarvis.aps.util.auth.BiometricAuthenticator
+import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs
+import dev.msfjarvis.aps.util.extensions.keyguardManager
+import dev.msfjarvis.aps.util.extensions.viewBinding
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+private enum class KeyGenType(val generateKey: suspend (requireAuthentication: Boolean) -> Unit) {
+ Rsa({ requireAuthentication ->
+ SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication)
+ }),
+ Ecdsa({ requireAuthentication ->
+ SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication)
+ }),
+ Ed25519({ requireAuthentication ->
+ SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication)
+ }),
+}
+
+class SshKeyGenActivity : AppCompatActivity() {
+
+ private var keyGenType = KeyGenType.Ecdsa
+ private val binding by viewBinding(ActivitySshKeygenBinding::inflate)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(binding.root)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ with(binding) {
+ generate.setOnClickListener {
+ if (SshKey.exists) {
+ MaterialAlertDialogBuilder(this@SshKeyGenActivity).run {
+ setTitle(R.string.ssh_keygen_existing_title)
+ setMessage(R.string.ssh_keygen_existing_message)
+ setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ ->
+ lifecycleScope.launch {
+ generate()
+ }
+ }
+ setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ ->
+ finish()
+ }
+ show()
+ }
+ } else {
+ lifecycleScope.launch {
+ generate()
+ }
+ }
+ }
+ keyTypeGroup.check(R.id.key_type_ecdsa)
+ keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa)
+ keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
+ if (isChecked) {
+ keyGenType = when (checkedId) {
+ R.id.key_type_ed25519 -> KeyGenType.Ed25519
+ R.id.key_type_ecdsa -> KeyGenType.Ecdsa
+ R.id.key_type_rsa -> KeyGenType.Rsa
+ else -> throw IllegalStateException("Impossible key type selection")
+ }
+ keyTypeExplanation.setText(when (keyGenType) {
+ KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519
+ KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa
+ KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa
+ })
+ }
+ }
+ keyRequireAuthentication.isEnabled = keyguardManager.isDeviceSecure
+ keyRequireAuthentication.isChecked = keyRequireAuthentication.isEnabled
+ }
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ // The back arrow in the action bar should act the same as the back button.
+ return when (item.itemId) {
+ android.R.id.home -> {
+ onBackPressed()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ private suspend fun generate() {
+ binding.generate.apply {
+ text = getString(R.string.ssh_key_gen_generating_progress)
+ isEnabled = false
+ }
+ binding.generate.text = getString(R.string.ssh_key_gen_generating_progress)
+ val result = runCatching {
+ withContext(Dispatchers.IO) {
+ val requireAuthentication = binding.keyRequireAuthentication.isChecked
+ if (requireAuthentication) {
+ val result = withContext(Dispatchers.Main) {
+ suspendCoroutine<BiometricAuthenticator.Result> { cont ->
+ BiometricAuthenticator.authenticate(this@SshKeyGenActivity, R.string.biometric_prompt_title_ssh_keygen) {
+ cont.resume(it)
+ }
+ }
+ }
+ if (result !is BiometricAuthenticator.Result.Success)
+ throw UserNotAuthenticatedException(getString(R.string.biometric_auth_generic_failure))
+ }
+ keyGenType.generateKey(requireAuthentication)
+ }
+ }
+ getEncryptedGitPrefs().edit {
+ remove("ssh_key_local_passphrase")
+ }
+ binding.generate.apply {
+ text = getString(R.string.ssh_keygen_generate)
+ isEnabled = true
+ }
+ result.fold(
+ success = {
+ ShowSshKeyFragment().show(supportFragmentManager, "public_key")
+ },
+ failure = { e ->
+ e.printStackTrace()
+ MaterialAlertDialogBuilder(this)
+ .setTitle(getString(R.string.error_generate_ssh_key))
+ .setMessage(getString(R.string.ssh_key_error_dialog_text) + e.message)
+ .setPositiveButton(getString(R.string.dialog_ok)) { _, _ ->
+ finish()
+ }
+ .show()
+ },
+ )
+ hideKeyboard()
+ }
+
+ private fun hideKeyboard() {
+ val imm = getSystemService<InputMethodManager>() ?: return
+ var view = currentFocus
+ if (view == null) {
+ view = View(this)
+ }
+ imm.hideSoftInputFromWindow(view.windowToken, 0)
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt b/app/src/main/java/dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt
new file mode 100644
index 00000000..356a914c
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.ui.util
+
+import androidx.recyclerview.widget.DefaultItemAnimator
+import androidx.recyclerview.widget.RecyclerView
+
+class OnOffItemAnimator : DefaultItemAnimator() {
+
+ var isEnabled: Boolean = true
+ set(value) {
+ // Defer update until no animation is running anymore.
+ isRunning { field = value }
+ }
+
+ private fun dontAnimate(viewHolder: RecyclerView.ViewHolder): Boolean {
+ dispatchAnimationFinished(viewHolder)
+ return false
+ }
+
+ override fun animateAppearance(
+ viewHolder: RecyclerView.ViewHolder,
+ preLayoutInfo: ItemHolderInfo?,
+ postLayoutInfo: ItemHolderInfo
+ ): Boolean {
+ return if (isEnabled) {
+ super.animateAppearance(viewHolder, preLayoutInfo, postLayoutInfo)
+ } else {
+ dontAnimate(viewHolder)
+ }
+ }
+
+ override fun animateChange(
+ oldHolder: RecyclerView.ViewHolder,
+ newHolder: RecyclerView.ViewHolder,
+ preInfo: ItemHolderInfo,
+ postInfo: ItemHolderInfo
+ ): Boolean {
+ return if (isEnabled) {
+ super.animateChange(oldHolder, newHolder, preInfo, postInfo)
+ } else {
+ dontAnimate(oldHolder)
+ }
+ }
+
+ override fun animateDisappearance(
+ viewHolder: RecyclerView.ViewHolder,
+ preLayoutInfo: ItemHolderInfo,
+ postLayoutInfo: ItemHolderInfo?
+ ): Boolean {
+ return if (isEnabled) {
+ super.animateDisappearance(viewHolder, preLayoutInfo, postLayoutInfo)
+ } else {
+ dontAnimate(viewHolder)
+ }
+ }
+
+ override fun animatePersistence(
+ viewHolder: RecyclerView.ViewHolder,
+ preInfo: ItemHolderInfo,
+ postInfo: ItemHolderInfo
+ ): Boolean {
+ return if (isEnabled) {
+ super.animatePersistence(viewHolder, preInfo, postInfo)
+ } else {
+ dontAnimate(viewHolder)
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt b/app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt
new file mode 100644
index 00000000..4b987ebe
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt
@@ -0,0 +1,78 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.auth
+
+import android.app.KeyguardManager
+import androidx.annotation.StringRes
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricManager.Authenticators
+import androidx.biometric.BiometricPrompt
+import androidx.core.content.ContextCompat
+import androidx.core.content.getSystemService
+import androidx.fragment.app.FragmentActivity
+import com.github.ajalt.timberkt.Timber.tag
+import com.github.ajalt.timberkt.d
+import dev.msfjarvis.aps.R
+
+object BiometricAuthenticator {
+
+ private const val TAG = "BiometricAuthenticator"
+ private const val validAuthenticators = Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK
+
+ sealed class Result {
+ data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result()
+ data class Failure(val code: Int?, val message: CharSequence) : Result()
+ object HardwareUnavailableOrDisabled : Result()
+ object Cancelled : Result()
+ }
+
+ fun canAuthenticate(activity: FragmentActivity): Boolean {
+ return BiometricManager.from(activity).canAuthenticate(validAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
+ }
+
+ fun authenticate(
+ activity: FragmentActivity,
+ @StringRes dialogTitleRes: Int = R.string.biometric_prompt_title,
+ callback: (Result) -> Unit
+ ) {
+ val authCallback = object : BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
+ super.onAuthenticationError(errorCode, errString)
+ tag(TAG).d { "BiometricAuthentication error: errorCode=$errorCode, msg=$errString" }
+ callback(when (errorCode) {
+ BiometricPrompt.ERROR_CANCELED, BiometricPrompt.ERROR_USER_CANCELED,
+ BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
+ Result.Cancelled
+ }
+ BiometricPrompt.ERROR_HW_NOT_PRESENT, BiometricPrompt.ERROR_HW_UNAVAILABLE,
+ BiometricPrompt.ERROR_NO_BIOMETRICS, BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> {
+ Result.HardwareUnavailableOrDisabled
+ }
+ else -> Result.Failure(errorCode, activity.getString(R.string.biometric_auth_error_reason, errString))
+ })
+ }
+
+ override fun onAuthenticationFailed() {
+ super.onAuthenticationFailed()
+ callback(Result.Failure(null, activity.getString(R.string.biometric_auth_error)))
+ }
+
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ super.onAuthenticationSucceeded(result)
+ callback(Result.Success(result.cryptoObject))
+ }
+ }
+ val deviceHasKeyguard = activity.getSystemService<KeyguardManager>()?.isDeviceSecure == true
+ if (canAuthenticate(activity) || deviceHasKeyguard) {
+ val promptInfo = BiometricPrompt.PromptInfo.Builder()
+ .setTitle(activity.getString(dialogTitleRes))
+ .setAllowedAuthenticators(validAuthenticators)
+ .build()
+ BiometricPrompt(activity, ContextCompat.getMainExecutor(activity.applicationContext), authCallback).authenticate(promptInfo)
+ } else {
+ callback(Result.HardwareUnavailableOrDisabled)
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt
new file mode 100644
index 00000000..2b0d56a3
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt
@@ -0,0 +1,189 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.util.autofill
+
+import android.content.Context
+import android.content.IntentSender
+import android.os.Build
+import android.service.autofill.Dataset
+import android.service.autofill.FillCallback
+import android.service.autofill.FillResponse
+import android.service.autofill.SaveInfo
+import android.view.inputmethod.InlineSuggestionsRequest
+import android.widget.inline.InlinePresentationSpec
+import androidx.annotation.RequiresApi
+import com.github.ajalt.timberkt.e
+import com.github.androidpasswordstore.autofillparser.AutofillAction
+import com.github.androidpasswordstore.autofillparser.FillableForm
+import com.github.androidpasswordstore.autofillparser.fillWith
+import com.github.michaelbull.result.fold
+import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivity
+import dev.msfjarvis.aps.ui.autofill.AutofillFilterView
+import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity
+import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity
+import dev.msfjarvis.aps.autofill.oreo.ui.AutofillSmsActivity
+import java.io.File
+
+/**
+ * Implements [AutofillResponseBuilder]'s methods for API 30 and above
+ */
+@RequiresApi(Build.VERSION_CODES.R)
+class Api30AutofillResponseBuilder(form: FillableForm) {
+
+ private val formOrigin = form.formOrigin
+ private val scenario = form.scenario
+ private val ignoredIds = form.ignoredIds
+ private val saveFlags = form.saveFlags
+ private val clientState = form.toClientState()
+
+ // We do not offer save when the only relevant field is a username field or there is no field.
+ private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave
+ private val canBeSaved = saveFlags != null && scenarioSupportsSave
+
+ private fun makeIntentDataset(
+ context: Context,
+ action: AutofillAction,
+ intentSender: IntentSender,
+ metadata: DatasetMetadata,
+ imeSpec: InlinePresentationSpec?,
+ ): Dataset {
+ return Dataset.Builder(makeRemoteView(context, metadata)).run {
+ fillWith(scenario, action, credentials = null)
+ setAuthentication(intentSender)
+ if (imeSpec != null) {
+ val inlinePresentation = makeInlinePresentation(context, imeSpec, metadata)
+ if (inlinePresentation != null) {
+ setInlinePresentation(inlinePresentation)
+ }
+ }
+ build()
+ }
+ }
+
+ private fun makeMatchDataset(context: Context, file: File, imeSpec: InlinePresentationSpec?): Dataset? {
+ if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
+ val metadata = makeFillMatchMetadata(context, file)
+ val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
+ return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
+ }
+
+ private fun makeSearchDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
+ if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
+ val metadata = makeSearchAndFillMetadata(context)
+ val intentSender =
+ AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
+ return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata, imeSpec)
+ }
+
+ private fun makeGenerateDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
+ if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null
+ val metadata = makeGenerateAndFillMetadata(context)
+ val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
+ return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata, imeSpec)
+ }
+
+
+ private fun makeFillOtpFromSmsDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
+ if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null
+ if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
+ val metadata = makeFillOtpFromSmsMetadata(context)
+ val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
+ return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata, imeSpec)
+ }
+
+ private fun makePublisherChangedDataset(
+ context: Context,
+ publisherChangedException: AutofillPublisherChangedException,
+ imeSpec: InlinePresentationSpec?
+ ): Dataset {
+ val metadata = makeWarningMetadata(context)
+ // If the user decides to trust the new publisher, they can choose reset the list of
+ // matches. In this case we need to immediately show a new `FillResponse` as if the app were
+ // autofilled for the first time. This `FillResponse` needs to be returned as a result from
+ // `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
+ val fillResponseAfterReset = makeFillResponse(context, null, emptyList())
+ val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
+ context, publisherChangedException, fillResponseAfterReset
+ )
+ return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
+ }
+
+ private fun makePublisherChangedResponse(
+ context: Context,
+ inlineSuggestionsRequest: InlineSuggestionsRequest?,
+ publisherChangedException: AutofillPublisherChangedException
+ ): FillResponse {
+ val imeSpec = inlineSuggestionsRequest?.inlinePresentationSpecs?.firstOrNull()
+ return FillResponse.Builder().run {
+ addDataset(makePublisherChangedDataset(context, publisherChangedException, imeSpec))
+ setIgnoredIds(*ignoredIds.toTypedArray())
+ build()
+ }
+ }
+
+ private fun makeFillResponse(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, matchedFiles: List<File>): FillResponse? {
+ var datasetCount = 0
+ val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()
+ return FillResponse.Builder().run {
+ for (file in matchedFiles) {
+ makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let {
+ datasetCount++
+ addDataset(it)
+ }
+ }
+ makeSearchDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
+ datasetCount++
+ addDataset(it)
+ }
+ makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
+ datasetCount++
+ addDataset(it)
+ }
+ makeFillOtpFromSmsDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
+ datasetCount++
+ addDataset(it)
+ }
+ if (datasetCount == 0) return null
+ setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true))))
+ makeSaveInfo()?.let { setSaveInfo(it) }
+ setClientState(clientState)
+ setIgnoredIds(*ignoredIds.toTypedArray())
+ build()
+ }
+ }
+
+ // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
+ // See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
+ private fun makeSaveInfo(): SaveInfo? {
+ if (!canBeSaved) return null
+ check(saveFlags != null)
+ val idsToSave = scenario.fieldsToSave.toTypedArray()
+ if (idsToSave.isEmpty()) return null
+ var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
+ if (scenario.hasUsername) {
+ saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
+ }
+ return SaveInfo.Builder(saveDataTypes, idsToSave).run {
+ setFlags(saveFlags)
+ build()
+ }
+ }
+
+ /**
+ * Creates and returns a suitable [FillResponse] to the Autofill framework.
+ */
+ fun fillCredentials(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, callback: FillCallback) {
+ AutofillMatcher.getMatchesFor(context, formOrigin).fold(
+ success = { matchedFiles ->
+ callback.onSuccess(makeFillResponse(context, inlineSuggestionsRequest, matchedFiles))
+ },
+ failure = { e ->
+ e(e)
+ callback.onSuccess(makePublisherChangedResponse(context, inlineSuggestionsRequest, e))
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt
new file mode 100644
index 00000000..52f74087
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt
@@ -0,0 +1,193 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.autofill
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.widget.Toast
+import androidx.core.content.edit
+import com.github.ajalt.timberkt.Timber.e
+import com.github.ajalt.timberkt.d
+import com.github.ajalt.timberkt.w
+import com.github.michaelbull.result.Err
+import com.github.michaelbull.result.Ok
+import com.github.michaelbull.result.Result
+import com.github.androidpasswordstore.autofillparser.FormOrigin
+import com.github.androidpasswordstore.autofillparser.computeCertificatesHash
+import dev.msfjarvis.aps.R
+import java.io.File
+
+private const val PREFERENCES_AUTOFILL_APP_MATCHES = "oreo_autofill_app_matches"
+private val Context.autofillAppMatches
+ get() = getSharedPreferences(PREFERENCES_AUTOFILL_APP_MATCHES, Context.MODE_PRIVATE)
+
+private const val PREFERENCES_AUTOFILL_WEB_MATCHES = "oreo_autofill_web_matches"
+private val Context.autofillWebMatches
+ get() = getSharedPreferences(PREFERENCES_AUTOFILL_WEB_MATCHES, Context.MODE_PRIVATE)
+
+private fun Context.matchPreferences(formOrigin: FormOrigin): SharedPreferences {
+ return when (formOrigin) {
+ is FormOrigin.App -> autofillAppMatches
+ is FormOrigin.Web -> autofillWebMatches
+ }
+}
+
+class AutofillPublisherChangedException(val formOrigin: FormOrigin) :
+ Exception("The publisher of '${formOrigin.identifier}' changed since an entry was first matched with this app") {
+
+ init {
+ require(formOrigin is FormOrigin.App)
+ }
+}
+
+/**
+ * Manages "matches", i.e., associations between apps or websites and Password Store entries.
+ */
+class AutofillMatcher {
+
+ companion object {
+
+ private const val MAX_NUM_MATCHES = 10
+
+ private const val PREFERENCE_PREFIX_TOKEN = "token;"
+ private fun tokenKey(formOrigin: FormOrigin.App) =
+ "$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}"
+
+ private const val PREFERENCE_PREFIX_MATCHES = "matches;"
+ private fun matchesKey(formOrigin: FormOrigin) =
+ "$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}"
+
+ private fun hasFormOriginHashChanged(context: Context, formOrigin: FormOrigin): Boolean {
+ return when (formOrigin) {
+ is FormOrigin.Web -> false
+ is FormOrigin.App -> {
+ val packageName = formOrigin.identifier
+ val certificatesHash = computeCertificatesHash(context, packageName)
+ val storedCertificatesHash =
+ context.autofillAppMatches.getString(tokenKey(formOrigin), null)
+ ?: return false
+ val hashHasChanged = certificatesHash != storedCertificatesHash
+ if (hashHasChanged) {
+ e { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" }
+ true
+ } else {
+ false
+ }
+ }
+ }
+ }
+
+ private fun storeFormOriginHash(context: Context, formOrigin: FormOrigin) {
+ if (formOrigin is FormOrigin.App) {
+ val packageName = formOrigin.identifier
+ val certificatesHash = computeCertificatesHash(context, packageName)
+ context.autofillAppMatches.edit {
+ putString(tokenKey(formOrigin), certificatesHash)
+ }
+ }
+ // We don't need to store a hash for FormOrigin.Web since it can only originate from
+ // browsers we trust to verify the origin.
+ }
+
+ /**
+ * Get all Password Store entries that have already been associated with [formOrigin] by the
+ * user.
+ *
+ * If [formOrigin] represents an app and that app's certificates have changed since the
+ * first time the user associated an entry with it, an [AutofillPublisherChangedException]
+ * will be thrown.
+ */
+ fun getMatchesFor(context: Context, formOrigin: FormOrigin): Result<List<File>, AutofillPublisherChangedException> {
+ if (hasFormOriginHashChanged(context, formOrigin)) {
+ return Err(AutofillPublisherChangedException(formOrigin))
+ }
+ val matchPreferences = context.matchPreferences(formOrigin)
+ val matchedFiles =
+ matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
+ return Ok(matchedFiles.filter { it.exists() }.also { validFiles ->
+ matchPreferences.edit {
+ putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet())
+ }
+ })
+ }
+
+ fun clearMatchesFor(context: Context, formOrigin: FormOrigin) {
+ context.matchPreferences(formOrigin).edit {
+ remove(matchesKey(formOrigin))
+ if (formOrigin is FormOrigin.App) remove(tokenKey(formOrigin))
+ }
+ }
+
+ /**
+ * Associates the store entry [file] with [formOrigin], such that future Autofill responses
+ * to requests from this app or website offer this entry as a dataset.
+ *
+ * The maximum number of matches is limited by [MAX_NUM_MATCHES] since older versions of
+ * Android may crash when too many datasets are offered.
+ */
+ fun addMatchFor(context: Context, formOrigin: FormOrigin, file: File) {
+ if (!file.exists()) return
+ if (hasFormOriginHashChanged(context, formOrigin)) {
+ // This should never happen since we already verified the publisher in
+ // getMatchesFor.
+ e { "App publisher changed between getMatchesFor and addMatchFor" }
+ throw AutofillPublisherChangedException(formOrigin)
+ }
+ val matchPreferences = context.matchPreferences(formOrigin)
+ val matchedFiles =
+ matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
+ val newFiles = setOf(file.absoluteFile).union(matchedFiles)
+ if (newFiles.size > MAX_NUM_MATCHES) {
+ Toast.makeText(
+ context,
+ context.getString(R.string.oreo_autofill_max_matches_reached, MAX_NUM_MATCHES),
+ Toast.LENGTH_LONG
+ ).show()
+ return
+ }
+ matchPreferences.edit {
+ putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet())
+ }
+ storeFormOriginHash(context, formOrigin)
+ d { "Stored match for $formOrigin" }
+ }
+
+ /**
+ * Goes through all existing matches and updates their associated entries by using
+ * [moveFromTo] as a lookup table and deleting the matches for files in [delete].
+ */
+ fun updateMatches(context: Context, moveFromTo: Map<File, File> = emptyMap(), delete: Collection<File> = emptyList()) {
+ val deletePathList = delete.map { it.absolutePath }
+ val oldNewPathMap = moveFromTo.mapValues { it.value.absolutePath }
+ .mapKeys { it.key.absolutePath }
+ for (prefs in listOf(context.autofillAppMatches, context.autofillWebMatches)) {
+ for ((key, value) in prefs.all) {
+ if (!key.startsWith(PREFERENCE_PREFIX_MATCHES)) continue
+ // We know that preferences starting with `PREFERENCE_PREFIX_MATCHES` were
+ // created with `putStringSet`.
+ @Suppress("UNCHECKED_CAST")
+ val oldMatches = value as? Set<String>
+ if (oldMatches == null) {
+ w { "Failed to read matches for $key" }
+ continue
+ }
+ // Delete all matches for file locations that are going to be overwritten, then
+ // transfer matches over to the files at their new locations.
+ val newMatches =
+ oldMatches.asSequence()
+ .minus(deletePathList)
+ .minus(oldNewPathMap.values)
+ .map { match ->
+ val newPath = oldNewPathMap[match] ?: return@map match
+ d { "Updating match for $key: $match --> $newPath" }
+ newPath
+ }.toSet()
+ if (newMatches != oldMatches)
+ prefs.edit { putStringSet(key, newMatches) }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt
new file mode 100644
index 00000000..aa70bacb
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt
@@ -0,0 +1,140 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.autofill
+
+import android.content.Context
+import android.os.Build
+import androidx.annotation.RequiresApi
+import com.github.androidpasswordstore.autofillparser.Credentials
+import dev.msfjarvis.aps.data.password.PasswordEntry
+import dev.msfjarvis.aps.util.extensions.sharedPrefs
+import dev.msfjarvis.aps.util.services.getDefaultUsername
+import java.io.File
+import java.nio.file.Paths
+
+enum class DirectoryStructure(val value: String) {
+ EncryptedUsername("encrypted_username"),
+ FileBased("file"),
+ DirectoryBased("directory");
+
+ /**
+ * Returns the username associated to [file], following the convention of the current
+ * [DirectoryStructure].
+ *
+ * Examples:
+ * - * --> null (EncryptedUsername)
+ * - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased)
+ * - work/example.org/john@doe.org/password.gpg --> john@doe.org (DirectoryBased)
+ * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
+ */
+ fun getUsernameFor(file: File): String? = when (this) {
+ EncryptedUsername -> null
+ FileBased -> file.nameWithoutExtension
+ DirectoryBased -> file.parentFile?.name ?: file.nameWithoutExtension
+ }
+
+ /**
+ * Returns the origin identifier associated to [file], following the convention of the current
+ * [DirectoryStructure].
+ *
+ * At least one of [DirectoryStructure.getIdentifierFor] and
+ * [DirectoryStructure.getAccountPartFor] will always return a non-null result.
+ *
+ * Examples:
+ * - work/example.org.gpg --> example.org (EncryptedUsername)
+ * - work/example.org/john@doe.org.gpg --> example.org (FileBased)
+ * - example.org.gpg --> example.org (FileBased, fallback)
+ * - work/example.org/john@doe.org/password.gpg --> example.org (DirectoryBased)
+ * - Temporary PIN.gpg --> null (DirectoryBased)
+ */
+ fun getIdentifierFor(file: File): String? = when (this) {
+ EncryptedUsername -> file.nameWithoutExtension
+ FileBased -> file.parentFile?.name ?: file.nameWithoutExtension
+ DirectoryBased -> file.parentFile?.parent
+ }
+
+ /**
+ * Returns the path components of [file] until right before the component that contains the
+ * origin identifier according to the current [DirectoryStructure].
+ *
+ * Examples:
+ * - work/example.org.gpg --> work (EncryptedUsername)
+ * - work/example.org/john@doe.org.gpg --> work (FileBased)
+ * - example.org/john@doe.org.gpg --> null (FileBased)
+ * - john@doe.org.gpg --> null (FileBased)
+ * - work/example.org/john@doe.org/password.gpg --> work (DirectoryBased)
+ * - example.org/john@doe.org/password.gpg --> null (DirectoryBased)
+ */
+ fun getPathToIdentifierFor(file: File): String? = when (this) {
+ EncryptedUsername -> file.parent
+ FileBased -> file.parentFile?.parent
+ DirectoryBased -> file.parentFile?.parentFile?.parent
+ }
+
+ /**
+ * Returns the path component of [file] following the origin identifier according to the current
+ * [DirectoryStructure] (without file extension).
+ *
+ * At least one of [DirectoryStructure.getIdentifierFor] and
+ * [DirectoryStructure.getAccountPartFor] will always return a non-null result.
+ *
+ * Examples:
+ * - * --> null (EncryptedUsername)
+ * - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased)
+ * - example.org.gpg --> null (FileBased, fallback)
+ * - work/example.org/john@doe.org/password.gpg --> john@doe.org/password (DirectoryBased)
+ * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
+ */
+ fun getAccountPartFor(file: File): String? = when (this) {
+ EncryptedUsername -> null
+ FileBased -> file.nameWithoutExtension.takeIf { file.parentFile != null }
+ DirectoryBased -> file.parentFile?.let { parentFile ->
+ "${parentFile.name}/${file.nameWithoutExtension}"
+ } ?: file.nameWithoutExtension
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ fun getSaveFolderName(sanitizedIdentifier: String, username: String?) = when (this) {
+ EncryptedUsername -> "/"
+ FileBased -> sanitizedIdentifier
+ DirectoryBased -> Paths.get(sanitizedIdentifier, username ?: "username").toString()
+ }
+
+ fun getSaveFileName(username: String?, identifier: String) = when (this) {
+ EncryptedUsername -> identifier
+ 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.sharedPrefs.getString(DirectoryStructure.PREFERENCE, null)
+ return DirectoryStructure.fromValue(value)
+ }
+
+ fun credentialsFromStoreEntry(
+ context: Context,
+ 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)
+ ?: context.getDefaultUsername()
+ return Credentials(username, entry.password, entry.calculateTotpCode())
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt
new file mode 100644
index 00000000..eecfc81b
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt
@@ -0,0 +1,205 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.autofill
+
+import android.content.Context
+import android.content.IntentSender
+import android.os.Build
+import android.os.Bundle
+import android.service.autofill.Dataset
+import android.service.autofill.FillCallback
+import android.service.autofill.FillResponse
+import android.service.autofill.SaveInfo
+import androidx.annotation.RequiresApi
+import com.github.ajalt.timberkt.e
+import com.github.androidpasswordstore.autofillparser.AutofillAction
+import com.github.androidpasswordstore.autofillparser.AutofillScenario
+import com.github.androidpasswordstore.autofillparser.Credentials
+import com.github.androidpasswordstore.autofillparser.FillableForm
+import com.github.androidpasswordstore.autofillparser.fillWith
+import com.github.michaelbull.result.fold
+import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivity
+import dev.msfjarvis.aps.ui.autofill.AutofillFilterView
+import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity
+import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity
+import dev.msfjarvis.aps.autofill.oreo.ui.AutofillSmsActivity
+import java.io.File
+
+@RequiresApi(Build.VERSION_CODES.O)
+class AutofillResponseBuilder(form: FillableForm) {
+
+ private val formOrigin = form.formOrigin
+ private val scenario = form.scenario
+ private val ignoredIds = form.ignoredIds
+ private val saveFlags = form.saveFlags
+ private val clientState = form.toClientState()
+
+ // We do not offer save when the only relevant field is a username field or there is no field.
+ private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave
+ private val canBeSaved = saveFlags != null && scenarioSupportsSave
+
+ private fun makeIntentDataset(
+ context: Context,
+ action: AutofillAction,
+ intentSender: IntentSender,
+ metadata: DatasetMetadata,
+ ): Dataset {
+ return Dataset.Builder(makeRemoteView(context, metadata)).run {
+ fillWith(scenario, action, credentials = null)
+ setAuthentication(intentSender)
+ build()
+ }
+ }
+
+ private fun makeMatchDataset(context: Context, file: File): Dataset? {
+ if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
+ val metadata = makeFillMatchMetadata(context, file)
+ val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
+ return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
+ }
+
+
+ private fun makeSearchDataset(context: Context): Dataset? {
+ if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
+ val metadata = makeSearchAndFillMetadata(context)
+ val intentSender =
+ AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
+ return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata)
+ }
+
+ private fun makeGenerateDataset(context: Context): Dataset? {
+ if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null
+ val metadata = makeGenerateAndFillMetadata(context)
+ val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
+ return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata)
+ }
+
+ private fun makeFillOtpFromSmsDataset(context: Context): Dataset? {
+ if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null
+ if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
+ val metadata = makeFillOtpFromSmsMetadata(context)
+ val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
+ return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata)
+ }
+
+ private fun makePublisherChangedDataset(
+ context: Context,
+ publisherChangedException: AutofillPublisherChangedException,
+ ): Dataset {
+ val metadata = makeWarningMetadata(context)
+ // If the user decides to trust the new publisher, they can choose reset the list of
+ // matches. In this case we need to immediately show a new `FillResponse` as if the app were
+ // autofilled for the first time. This `FillResponse` needs to be returned as a result from
+ // `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
+ val fillResponseAfterReset = makeFillResponse(context, emptyList())
+ val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
+ context, publisherChangedException, fillResponseAfterReset
+ )
+ return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
+ }
+
+ private fun makePublisherChangedResponse(
+ context: Context,
+ publisherChangedException: AutofillPublisherChangedException
+ ): FillResponse {
+ return FillResponse.Builder().run {
+ addDataset(makePublisherChangedDataset(context, publisherChangedException))
+ setIgnoredIds(*ignoredIds.toTypedArray())
+ build()
+ }
+ }
+
+ // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
+ // See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
+ private fun makeSaveInfo(): SaveInfo? {
+ if (!canBeSaved) return null
+ check(saveFlags != null)
+ val idsToSave = scenario.fieldsToSave.toTypedArray()
+ if (idsToSave.isEmpty()) return null
+ var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
+ if (scenario.hasUsername) {
+ saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
+ }
+ return SaveInfo.Builder(saveDataTypes, idsToSave).run {
+ setFlags(saveFlags)
+ build()
+ }
+ }
+
+ private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? {
+ var datasetCount = 0
+ return FillResponse.Builder().run {
+ for (file in matchedFiles) {
+ makeMatchDataset(context, file)?.let {
+ datasetCount++
+ addDataset(it)
+ }
+ }
+ makeSearchDataset(context)?.let {
+ datasetCount++
+ addDataset(it)
+ }
+ makeGenerateDataset(context)?.let {
+ datasetCount++
+ addDataset(it)
+ }
+ makeFillOtpFromSmsDataset(context)?.let {
+ datasetCount++
+ addDataset(it)
+ }
+ if (datasetCount == 0) return null
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true))))
+ }
+ makeSaveInfo()?.let { setSaveInfo(it) }
+ setClientState(clientState)
+ setIgnoredIds(*ignoredIds.toTypedArray())
+ build()
+ }
+ }
+
+ /**
+ * Creates and returns a suitable [FillResponse] to the Autofill framework.
+ */
+ fun fillCredentials(context: Context, callback: FillCallback) {
+ AutofillMatcher.getMatchesFor(context, formOrigin).fold(
+ success = { matchedFiles ->
+ callback.onSuccess(makeFillResponse(context, matchedFiles))
+ },
+ failure = { e ->
+ e(e)
+ callback.onSuccess(makePublisherChangedResponse(context, e))
+ }
+ )
+ }
+
+ companion object {
+
+ fun makeFillInDataset(
+ context: Context,
+ credentials: Credentials,
+ clientState: Bundle,
+ action: AutofillAction
+ ): Dataset {
+ val scenario = AutofillScenario.fromClientState(clientState)
+ // Before Android P, Datasets used for fill-in had to come with a RemoteViews, even
+ // though they are rarely shown.
+ // FIXME: We should clone the original dataset here and add the credentials to be filled
+ // in. Otherwise, the entry in the cached list of datasets will be overwritten by the
+ // fill-in dataset without any visual representation. This causes it to be missing from
+ // the Autofill suggestions shown after the user clears the filled out form fields.
+ val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ Dataset.Builder()
+ } else {
+ Dataset.Builder(makeRemoteView(context, makeEmptyMetadata()))
+ }
+ return builder.run {
+ if (scenario != null) fillWith(scenario, action, credentials)
+ else e { "Failed to recover scenario from client state" }
+ build()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt
new file mode 100644
index 00000000..6055c837
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt
@@ -0,0 +1,113 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.autofill
+
+import android.annotation.SuppressLint
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.res.Resources
+import android.graphics.drawable.Icon
+import android.os.Build
+import android.service.autofill.InlinePresentation
+import android.view.View
+import android.widget.RemoteViews
+import android.widget.inline.InlinePresentationSpec
+import androidx.annotation.DrawableRes
+import androidx.autofill.inline.UiVersions
+import androidx.autofill.inline.v1.InlineSuggestionUi
+import dev.msfjarvis.aps.ui.passwords.PasswordStore
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.data.repo.PasswordRepository
+import java.io.File
+
+data class DatasetMetadata(val title: String, val subtitle: String?, @DrawableRes val iconRes: Int)
+
+fun makeRemoteView(context: Context, metadata: DatasetMetadata): RemoteViews {
+ return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply {
+ setTextViewText(R.id.title, metadata.title)
+ if (metadata.subtitle != null) {
+ setTextViewText(R.id.summary, metadata.subtitle)
+ } else {
+ setViewVisibility(R.id.summary, View.GONE)
+ }
+ if (metadata.iconRes != Resources.ID_NULL) {
+ setImageViewResource(R.id.icon, metadata.iconRes)
+ } else {
+ setViewVisibility(R.id.icon, View.GONE)
+ }
+ }
+}
+
+@SuppressLint("RestrictedApi")
+fun makeInlinePresentation(context: Context, imeSpec: InlinePresentationSpec, metadata: DatasetMetadata): InlinePresentation? {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R)
+ return null
+
+ if (UiVersions.INLINE_UI_VERSION_1 !in UiVersions.getVersions(imeSpec.style))
+ return null
+
+ val launchIntent = PendingIntent.getActivity(context, 0, Intent(context, PasswordStore::class.java), 0)
+ val slice = InlineSuggestionUi.newContentBuilder(launchIntent).run {
+ setTitle(metadata.title)
+ if (metadata.subtitle != null)
+ setSubtitle(metadata.subtitle)
+ setContentDescription(if (metadata.subtitle != null) "${metadata.title} - ${metadata.subtitle}" else metadata.title)
+ setStartIcon(Icon.createWithResource(context, metadata.iconRes))
+ build().slice
+ }
+
+ return InlinePresentation(slice, imeSpec, false)
+}
+
+
+fun makeFillMatchMetadata(context: Context, file: File): DatasetMetadata {
+ val directoryStructure = AutofillPreferences.directoryStructure(context)
+ val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory())
+ val title = directoryStructure.getIdentifierFor(relativeFile)
+ ?: directoryStructure.getAccountPartFor(relativeFile)!!
+ val subtitle = directoryStructure.getAccountPartFor(relativeFile)
+ return DatasetMetadata(
+ title,
+ subtitle,
+ R.drawable.ic_person_black_24dp
+ )
+}
+
+fun makeSearchAndFillMetadata(context: Context) = DatasetMetadata(
+ context.getString(R.string.oreo_autofill_search_in_store),
+ null,
+ R.drawable.ic_search_black_24dp
+)
+
+fun makeGenerateAndFillMetadata(context: Context) = DatasetMetadata(
+ context.getString(R.string.oreo_autofill_generate_password),
+ null,
+ R.drawable.ic_autofill_new_password
+)
+
+fun makeFillOtpFromSmsMetadata(context: Context) = DatasetMetadata(
+ context.getString(R.string.oreo_autofill_fill_otp_from_sms),
+ null,
+ R.drawable.ic_autofill_sms
+)
+
+fun makeEmptyMetadata() = DatasetMetadata(
+ "PLACEHOLDER",
+ "PLACEHOLDER",
+ R.mipmap.ic_launcher
+)
+
+fun makeWarningMetadata(context: Context) = DatasetMetadata(
+ context.getString(R.string.oreo_autofill_warning_publisher_dataset_title),
+ context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary),
+ R.drawable.ic_warning_red_24dp
+)
+
+fun makeHeaderMetadata(title: String) = DatasetMetadata(
+ title,
+ null,
+ 0
+)
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt b/app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt
new file mode 100644
index 00000000..308c5966
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt
@@ -0,0 +1,179 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.util.extensions
+
+import android.app.KeyguardManager
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.SharedPreferences
+import android.content.pm.PackageManager
+import android.os.Build
+import android.util.Base64
+import android.util.TypedValue
+import android.view.View
+import android.view.autofill.AutofillManager
+import android.view.inputmethod.InputMethodManager
+import androidx.annotation.IdRes
+import androidx.annotation.RequiresApi
+import androidx.appcompat.app.AlertDialog
+import androidx.core.content.ContextCompat
+import androidx.core.content.getSystemService
+import androidx.fragment.app.FragmentActivity
+import androidx.preference.PreferenceManager
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import com.github.ajalt.timberkt.d
+import com.github.michaelbull.result.Ok
+import com.github.michaelbull.result.Result
+import com.google.android.material.snackbar.Snackbar
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.data.repo.PasswordRepository
+import dev.msfjarvis.aps.util.git.operation.GitOperation
+
+/**
+ * Extension function for [AlertDialog] that requests focus for the
+ * view whose id is [id]. Solution based on a StackOverflow
+ * answer: https://stackoverflow.com/a/13056259/297261
+ */
+fun <T : View> AlertDialog.requestInputFocusOnView(@IdRes id: Int) {
+ setOnShowListener {
+ findViewById<T>(id)?.apply {
+ setOnFocusChangeListener { v, _ ->
+ v.post {
+ context.getSystemService<InputMethodManager>()
+ ?.showSoftInput(v, InputMethodManager.SHOW_IMPLICIT)
+ }
+ }
+ requestFocus()
+ }
+ }
+}
+
+/**
+ * Get an instance of [AutofillManager]. Only
+ * available on Android Oreo and above
+ */
+val Context.autofillManager: AutofillManager?
+ @RequiresApi(Build.VERSION_CODES.O)
+ get() = getSystemService()
+
+/**
+ * Get an instance of [ClipboardManager]
+ */
+val Context.clipboard
+ get() = getSystemService<ClipboardManager>()
+
+/**
+ * Wrapper for [getEncryptedPrefs] to avoid open-coding the file name at
+ * each call site
+ */
+fun Context.getEncryptedGitPrefs() = getEncryptedPrefs("git_operation")
+
+/**
+ * Wrapper for [getEncryptedPrefs] to get the encrypted preference set for the HTTP
+ * proxy.
+ */
+fun Context.getEncryptedProxyPrefs() = getEncryptedPrefs("http_proxy")
+
+/**
+ * Get an instance of [EncryptedSharedPreferences] with the given [fileName]
+ */
+private fun Context.getEncryptedPrefs(fileName: String): SharedPreferences {
+ val masterKeyAlias = MasterKey.Builder(applicationContext)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+ return EncryptedSharedPreferences.create(
+ applicationContext,
+ fileName,
+ masterKeyAlias,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+}
+
+/**
+ * Get an instance of [KeyguardManager]
+ */
+val Context.keyguardManager: KeyguardManager
+ get() = getSystemService()!!
+
+/**
+ * Get the default [SharedPreferences] instance
+ */
+val Context.sharedPrefs: SharedPreferences
+ get() = PreferenceManager.getDefaultSharedPreferences(applicationContext)
+
+
+/**
+ * Resolve [attr] from the [Context]'s theme
+ */
+fun Context.resolveAttribute(attr: Int): Int {
+ val typedValue = TypedValue()
+ this.theme.resolveAttribute(attr, typedValue, true)
+ return typedValue.data
+}
+
+/**
+ * Commit changes to the store from a [FragmentActivity] using
+ * a custom implementation of [GitOperation]
+ */
+suspend fun FragmentActivity.commitChange(
+ message: String,
+): Result<Unit, Throwable> {
+ if (!PasswordRepository.isGitRepo()) {
+ return Ok(Unit)
+ }
+ return object : GitOperation(this@commitChange) {
+ override val commands = arrayOf(
+ // Stage all files
+ git.add().addFilepattern("."),
+ // Populate the changed files count
+ git.status(),
+ // Commit everything! If anything changed, that is.
+ git.commit().setAll(true).setMessage(message),
+ )
+
+ override fun preExecute(): Boolean {
+ d { "Committing with message: '$message'" }
+ return true
+ }
+ }.execute()
+}
+
+/**
+ * Check if [permission] has been granted to the app.
+ */
+fun FragmentActivity.isPermissionGranted(permission: String): Boolean {
+ return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
+}
+
+/**
+ * Show a [Snackbar] in a [FragmentActivity] and correctly
+ * anchor it to a [com.google.android.material.floatingactionbutton.FloatingActionButton]
+ * if one exists in the [view]
+ */
+fun FragmentActivity.snackbar(
+ view: View = findViewById(android.R.id.content),
+ message: String,
+ length: Int = Snackbar.LENGTH_SHORT,
+): Snackbar {
+ val snackbar = Snackbar.make(view, message, length)
+ snackbar.anchorView = findViewById(R.id.fab)
+ snackbar.show()
+ return snackbar
+}
+
+/**
+ * Simplifies the common `getString(key, null) ?: defaultValue` case slightly
+ */
+fun SharedPreferences.getString(key: String): String? = getString(key, null)
+
+/**
+ * Convert this [String] to its [Base64] representation
+ */
+fun String.base64(): String {
+ return Base64.encodeToString(encodeToByteArray(), Base64.NO_WRAP)
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt b/app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt
new file mode 100644
index 00000000..dbfc0f63
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.extensions
+
+import com.github.michaelbull.result.getOrElse
+import com.github.michaelbull.result.runCatching
+import dev.msfjarvis.aps.data.repo.PasswordRepository
+import java.io.File
+import java.util.Date
+import org.eclipse.jgit.lib.ObjectId
+import org.eclipse.jgit.revwalk.RevCommit
+
+/**
+ * The default OpenPGP provider for the app
+ */
+const val OPENPGP_PROVIDER = "org.sufficientlysecure.keychain"
+
+/**
+ * Clears the given [flag] from the value of this [Int]
+ */
+fun Int.clearFlag(flag: Int): Int {
+ return this and flag.inv()
+}
+
+/**
+ * Checks if this [Int] contains the given [flag]
+ */
+infix fun Int.hasFlag(flag: Int): Boolean {
+ return this and flag == flag
+}
+
+/**
+ * Checks whether this [File] is a directory that contains [other].
+ */
+fun File.contains(other: File): Boolean {
+ if (!isDirectory)
+ return false
+ if (!other.exists())
+ return false
+ val relativePath = runCatching {
+ other.relativeTo(this)
+ }.getOrElse {
+ return false
+ }
+ // Direct containment is equivalent to the relative path being equal to the filename.
+ return relativePath.path == other.name
+}
+
+/**
+ * Checks if this [File] is in the password repository directory as given
+ * by [PasswordRepository.getRepositoryDirectory]
+ */
+fun File.isInsideRepository(): Boolean {
+ return canonicalPath.contains(PasswordRepository.getRepositoryDirectory().canonicalPath)
+}
+
+/**
+ * Recursively lists the files in this [File], skipping any directories it encounters.
+ */
+fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toList()
+
+/**
+ * Unique SHA-1 hash of this commit as hexadecimal string.
+ *
+ * @see RevCommit.getId
+ */
+val RevCommit.hash: String
+ get() = ObjectId.toString(id)
+
+/**
+ * Time this commit was made with second precision.
+ *
+ * @see RevCommit.commitTime
+ */
+val RevCommit.time: Date
+ get() {
+ val epochSeconds = commitTime.toLong()
+ val epochMilliseconds = epochSeconds * 1000
+ return Date(epochMilliseconds)
+ }
+
+/**
+ * Splits this [String] into an [Array] of [String]s, split on the UNIX LF line ending
+ * and stripped of any empty lines.
+ */
+fun String.splitLines(): Array<String> {
+ return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt b/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt
new file mode 100644
index 00000000..642e0f22
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt
@@ -0,0 +1,36 @@
+package dev.msfjarvis.aps.util.extensions
+
+import androidx.annotation.IdRes
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.commit
+import dev.msfjarvis.aps.R
+
+/**
+ * Check if [permission] is granted to the app. Aliases to [isPermissionGranted] internally.
+ */
+fun Fragment.isPermissionGranted(permission: String): Boolean {
+ return requireActivity().isPermissionGranted(permission)
+}
+
+/**
+ * Calls `finish()` on the enclosing [androidx.fragment.app.FragmentActivity]
+ */
+fun Fragment.finish() = requireActivity().finish()
+
+/**
+ * Perform a [commit] on this [FragmentManager] with custom animations and adding the [destinationFragment]
+ * to the fragment backstack
+ */
+fun FragmentManager.performTransactionWithBackStack(destinationFragment: Fragment, @IdRes containerViewId: Int = android.R.id.content) {
+ commit {
+ beginTransaction()
+ addToBackStack(destinationFragment.tag)
+ setCustomAnimations(
+ R.animator.slide_in_left,
+ R.animator.slide_out_left,
+ R.animator.slide_in_right,
+ R.animator.slide_out_right)
+ replace(containerViewId, destinationFragment)
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt b/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt
new file mode 100644
index 00000000..03342d31
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.util.extensions
+
+
+import android.view.LayoutInflater
+import android.view.View
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.viewbinding.ViewBinding
+import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KProperty
+
+/**
+ * Imported from https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c
+ */
+class FragmentViewBindingDelegate<T : ViewBinding>(
+ val fragment: Fragment,
+ val viewBindingFactory: (View) -> T
+) : ReadOnlyProperty<Fragment, T> {
+
+ private var binding: T? = null
+
+ init {
+ fragment.lifecycle.addObserver(object : DefaultLifecycleObserver {
+ override fun onCreate(owner: LifecycleOwner) {
+ fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner ->
+ viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
+ override fun onDestroy(owner: LifecycleOwner) {
+ binding = null
+ }
+ })
+ }
+ }
+ })
+ }
+
+ override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
+ val binding = binding
+ if (binding != null) {
+ return binding
+ }
+
+ val lifecycle = fragment.viewLifecycleOwner.lifecycle
+ if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
+ throw IllegalStateException("Should not attempt to get bindings when Fragment views are destroyed.")
+ }
+
+ return viewBindingFactory(thisRef.requireView()).also { this.binding = it }
+ }
+}
+
+fun <T : ViewBinding> Fragment.viewBinding(viewBindingFactory: (View) -> T) =
+ FragmentViewBindingDelegate(this, viewBindingFactory)
+
+inline fun <T : ViewBinding> AppCompatActivity.viewBinding(crossinline bindingInflater: (LayoutInflater) -> T) =
+ lazy(LazyThreadSafetyMode.NONE) {
+ bindingInflater.invoke(layoutInflater)
+ }
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt
new file mode 100644
index 00000000..44eb11e1
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.util.git
+
+import android.os.RemoteException
+import androidx.annotation.StringRes
+import dev.msfjarvis.aps.Application
+import dev.msfjarvis.aps.R
+import java.net.UnknownHostException
+
+/**
+ * Supertype for all Git-related [Exception]s that can be thrown by [GitCommandExecutor.execute].
+ */
+sealed class GitException(@StringRes res: Int, vararg fmt: String) : Exception(buildMessage(res, *fmt)) {
+
+ override val message = super.message!!
+
+ companion object {
+
+ private fun buildMessage(@StringRes res: Int, vararg fmt: String) = Application.instance.resources.getString(res, *fmt)
+ }
+
+ /**
+ * Encapsulates possible errors from a [org.eclipse.jgit.api.PullCommand].
+ */
+ sealed class PullException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
+
+ object PullRebaseFailed : PullException(R.string.git_pull_fail_error)
+ }
+
+ /**
+ * Encapsulates possible errors from a [org.eclipse.jgit.api.PushCommand].
+ */
+ sealed class PushException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
+
+ object NonFastForward : PushException(R.string.git_push_nff_error)
+ object RemoteRejected : PushException(R.string.git_push_other_error)
+ class Generic(message: String) : PushException(R.string.git_push_generic_error, message)
+ }
+}
+
+object ErrorMessages {
+
+ operator fun get(throwable: Throwable?): String {
+ val resources = Application.instance.resources
+ if (throwable == null) return resources.getString(R.string.git_unknown_error)
+ return when (val rootCause = rootCause(throwable)) {
+ is GitException -> rootCause.message
+ is UnknownHostException -> resources.getString(R.string.git_unknown_host, throwable.message)
+ else -> throwable.message ?: resources.getString(R.string.git_unknown_error)
+ }
+ }
+
+ private fun rootCause(throwable: Throwable): Throwable {
+ var cause = throwable
+ while (cause.cause != null) {
+ if (cause is GitException) break
+ val nextCause = cause.cause!!
+ if (nextCause is RemoteException) break
+ cause = nextCause
+ }
+ return cause
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt
new file mode 100644
index 00000000..429ea2c5
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt
@@ -0,0 +1,117 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.util.git
+
+import android.widget.Toast
+import androidx.fragment.app.FragmentActivity
+import com.github.michaelbull.result.Result
+import com.github.michaelbull.result.runCatching
+import com.google.android.material.snackbar.Snackbar
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.util.git.GitException.PullException
+import dev.msfjarvis.aps.util.git.GitException.PushException
+import dev.msfjarvis.aps.util.settings.GitSettings
+import dev.msfjarvis.aps.util.git.operation.GitOperation
+import dev.msfjarvis.aps.util.extensions.snackbar
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.eclipse.jgit.api.CommitCommand
+import org.eclipse.jgit.api.PullCommand
+import org.eclipse.jgit.api.PushCommand
+import org.eclipse.jgit.api.RebaseResult
+import org.eclipse.jgit.api.StatusCommand
+import org.eclipse.jgit.lib.PersonIdent
+import org.eclipse.jgit.transport.RemoteRefUpdate
+
+class GitCommandExecutor(
+ private val activity: FragmentActivity,
+ private val operation: GitOperation,
+) {
+
+ suspend fun execute(): Result<Unit, Throwable> {
+ val snackbar = activity.snackbar(
+ message = activity.resources.getString(R.string.git_operation_running),
+ length = Snackbar.LENGTH_INDEFINITE,
+ )
+ // Count the number of uncommitted files
+ var nbChanges = 0
+ return runCatching {
+ for (command in operation.commands) {
+ when (command) {
+ is StatusCommand -> {
+ val res = withContext(Dispatchers.IO) {
+ command.call()
+ }
+ nbChanges = res.uncommittedChanges.size
+ }
+ is CommitCommand -> {
+ // the previous status will eventually be used to avoid a commit
+ if (nbChanges > 0) {
+ withContext(Dispatchers.IO) {
+ val name = GitSettings.authorName.ifEmpty { "root" }
+ val email = GitSettings.authorEmail.ifEmpty { "localhost" }
+ val identity = PersonIdent(name, email)
+ command.setAuthor(identity).setCommitter(identity).call()
+ }
+ }
+ }
+ is PullCommand -> {
+ val result = withContext(Dispatchers.IO) {
+ command.call()
+ }
+ val rr = result.rebaseResult
+ if (rr.status == RebaseResult.Status.STOPPED) {
+ throw PullException.PullRebaseFailed
+ }
+ }
+ is PushCommand -> {
+ val results = withContext(Dispatchers.IO) {
+ command.call()
+ }
+ for (result in results) {
+ // Code imported (modified) from Gerrit PushOp, license Apache v2
+ for (rru in result.remoteUpdates) {
+ when (rru.status) {
+ RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD -> throw PushException.NonFastForward
+ RemoteRefUpdate.Status.REJECTED_NODELETE,
+ RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED,
+ RemoteRefUpdate.Status.NON_EXISTING,
+ RemoteRefUpdate.Status.NOT_ATTEMPTED,
+ -> throw PushException.Generic(rru.status.name)
+ RemoteRefUpdate.Status.REJECTED_OTHER_REASON -> {
+ throw if ("non-fast-forward" == rru.message) {
+ PushException.RemoteRejected
+ } else {
+ PushException.Generic(rru.message)
+ }
+ }
+ RemoteRefUpdate.Status.UP_TO_DATE -> {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(
+ activity.applicationContext,
+ activity.applicationContext.getString(R.string.git_push_up_to_date),
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+ else -> {
+ }
+ }
+ }
+ }
+ }
+ else -> {
+ withContext(Dispatchers.IO) {
+ command.call()
+ }
+ }
+ }
+ }
+ }.also {
+ snackbar.dismiss()
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommit.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommit.kt
new file mode 100644
index 00000000..6e8e1c0c
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommit.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.util.git
+
+import java.util.Date
+
+/**
+ * Basic information about a git commit.
+ *
+ * @property hash full-length hash of the commit object.
+ * @property shortMessage the commit's short message (i.e. title line).
+ * @property authorName name of the commit's author without email address.
+ * @property time time when the commit was created.
+ */
+data class GitCommit(val hash: String, val shortMessage: String, val authorName: String, val time: Date)
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt
new file mode 100644
index 00000000..f6bbd55d
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.util.git
+
+import com.github.ajalt.timberkt.e
+import com.github.michaelbull.result.getOrElse
+import com.github.michaelbull.result.runCatching
+import dev.msfjarvis.aps.data.repo.PasswordRepository
+import dev.msfjarvis.aps.util.extensions.hash
+import dev.msfjarvis.aps.util.extensions.time
+import org.eclipse.jgit.api.Git
+import org.eclipse.jgit.revwalk.RevCommit
+
+private fun commits(): Iterable<RevCommit> {
+ val repo = PasswordRepository.getRepository(null)
+ if (repo == null) {
+ e { "Could not access git repository" }
+ return listOf()
+ }
+ return runCatching {
+ Git(repo).log().call()
+ }.getOrElse { e ->
+ e(e) { "Failed to obtain git commits" }
+ listOf()
+ }
+}
+
+/**
+ * Provides [GitCommit]s from a git-log of the password git repository.
+ *
+ * All commits are acquired on the first request to this object.
+ */
+class GitLogModel {
+
+ // All commits are acquired here at once. Acquiring the commits in batches would not have been
+ // entirely sensible because the amount of computation required to obtain commit number n from
+ // the log includes the amount of computation required to obtain commit number n-1 from the log.
+ // This is because the commit graph is walked from HEAD to the last commit to obtain.
+ // Additionally, tests with 1000 commits in the log have not produced a significant delay in the
+ // user experience.
+ private val cache: MutableList<GitCommit> by lazy(LazyThreadSafetyMode.NONE) {
+ commits().map {
+ GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time)
+ }.toMutableList()
+ }
+ val size = cache.size
+
+ fun get(index: Int): GitCommit? {
+ if (index >= size) e { "Cannot get git commit with index $index. There are only $size." }
+ return cache.getOrNull(index)
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt
new file mode 100644
index 00000000..1aff34de
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.git.operation
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
+import org.eclipse.jgit.api.RebaseCommand
+
+class BreakOutOfDetached(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
+
+ override val commands = arrayOf(
+ // abort the rebase
+ git.rebase().setOperation(RebaseCommand.Operation.ABORT),
+ // git checkout -b conflict-branch
+ git.checkout().setCreateBranch(true).setName("conflicting-$remoteBranch-${System.currentTimeMillis()}"),
+ // push the changes
+ git.push().setRemote("origin"),
+ // switch back to ${gitBranch}
+ git.checkout().setName(remoteBranch),
+ )
+
+ override fun preExecute() = if (!git.repository.repositoryState.isRebasing) {
+ MaterialAlertDialogBuilder(callingActivity)
+ .setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title))
+ .setMessage(callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded))
+ .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ ->
+ callingActivity.finish()
+ }.show()
+ false
+ } else {
+ true
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt
new file mode 100644
index 00000000..e1dd6760
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.git.operation
+
+import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
+import org.eclipse.jgit.api.Git
+import org.eclipse.jgit.api.GitCommand
+
+/**
+ * Creates a new clone operation
+ *
+ * @param uri URL to clone the repository from
+ * @param callingActivity the calling activity
+ */
+class CloneOperation(callingActivity: ContinuationContainerActivity, uri: String) : GitOperation(callingActivity) {
+
+ override val commands: Array<GitCommand<out Any>> = arrayOf(
+ Git.cloneRepository().setBranch(remoteBranch).setDirectory(repository.workTree).setURI(uri),
+ )
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt
new file mode 100644
index 00000000..173b7a50
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt
@@ -0,0 +1,98 @@
+package dev.msfjarvis.aps.util.git.operation
+
+import android.annotation.SuppressLint
+import android.view.LayoutInflater
+import androidx.annotation.StringRes
+import androidx.core.content.edit
+import androidx.core.widget.doOnTextChanged
+import androidx.fragment.app.FragmentActivity
+import com.google.android.material.checkbox.MaterialCheckBox
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.textfield.TextInputEditText
+import com.google.android.material.textfield.TextInputLayout
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.util.settings.AuthMode
+import dev.msfjarvis.aps.util.git.sshj.InteractivePasswordFinder
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs
+import dev.msfjarvis.aps.util.extensions.requestInputFocusOnView
+import kotlin.coroutines.Continuation
+import kotlin.coroutines.resume
+
+class CredentialFinder(
+ val callingActivity: FragmentActivity,
+ val authMode: AuthMode
+) : InteractivePasswordFinder() {
+
+ override fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) {
+ val gitOperationPrefs = callingActivity.getEncryptedGitPrefs()
+ val credentialPref: String
+ @StringRes val messageRes: Int
+ @StringRes val hintRes: Int
+ @StringRes val rememberRes: Int
+ @StringRes val errorRes: Int
+ when (authMode) {
+ AuthMode.SshKey -> {
+ credentialPref = PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE
+ messageRes = R.string.passphrase_dialog_text
+ hintRes = R.string.ssh_keygen_passphrase
+ rememberRes = R.string.git_operation_remember_passphrase
+ errorRes = R.string.git_operation_wrong_passphrase
+ }
+ AuthMode.Password -> {
+ // Could be either an SSH or an HTTPS password
+ credentialPref = PreferenceKeys.HTTPS_PASSWORD
+ messageRes = R.string.password_dialog_text
+ hintRes = R.string.git_operation_hint_password
+ rememberRes = R.string.git_operation_remember_password
+ errorRes = R.string.git_operation_wrong_password
+ }
+ else -> throw IllegalStateException("Only SshKey and Password connection mode ask for passwords")
+ }
+ if (isRetry)
+ gitOperationPrefs.edit { remove(credentialPref) }
+ val storedCredential = gitOperationPrefs.getString(credentialPref, null)
+ if (storedCredential == null) {
+ val layoutInflater = LayoutInflater.from(callingActivity)
+
+ @SuppressLint("InflateParams")
+ val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null)
+ val credentialLayout = dialogView.findViewById<TextInputLayout>(R.id.git_auth_passphrase_layout)
+ val editCredential = dialogView.findViewById<TextInputEditText>(R.id.git_auth_credential)
+ editCredential.setHint(hintRes)
+ val rememberCredential = dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_credential)
+ rememberCredential.setText(rememberRes)
+ if (isRetry) {
+ credentialLayout.error = callingActivity.resources.getString(errorRes)
+ // Reset error when user starts entering a password
+ editCredential.doOnTextChanged { _, _, _, _ -> credentialLayout.error = null }
+ }
+ MaterialAlertDialogBuilder(callingActivity).run {
+ setTitle(R.string.passphrase_dialog_title)
+ setMessage(messageRes)
+ setView(dialogView)
+ setPositiveButton(R.string.dialog_ok) { _, _ ->
+ val credential = editCredential.text.toString()
+ if (rememberCredential.isChecked) {
+ gitOperationPrefs.edit {
+ putString(credentialPref, credential)
+ }
+ }
+ cont.resume(credential)
+ }
+ setNegativeButton(R.string.dialog_cancel) { _, _ ->
+ cont.resume(null)
+ }
+ setOnCancelListener {
+ cont.resume(null)
+ }
+ create()
+ }.run {
+ requestInputFocusOnView<TextInputEditText>(R.id.git_auth_credential)
+ show()
+ }
+ } else {
+ cont.resume(storedCredential)
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt
new file mode 100644
index 00000000..44292fc6
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt
@@ -0,0 +1,216 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.git.operation
+
+import android.content.Intent
+import android.widget.Toast
+import androidx.fragment.app.FragmentActivity
+import com.github.ajalt.timberkt.e
+import com.github.michaelbull.result.Err
+import com.github.michaelbull.result.Ok
+import com.github.michaelbull.result.Result
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.runCatching
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.ui.settings.UserPreference
+import dev.msfjarvis.aps.util.git.GitCommandExecutor
+import dev.msfjarvis.aps.util.settings.AuthMode
+import dev.msfjarvis.aps.util.settings.GitSettings
+import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
+import dev.msfjarvis.aps.util.git.sshj.SshAuthMethod
+import dev.msfjarvis.aps.util.git.sshj.SshKey
+import dev.msfjarvis.aps.util.git.sshj.SshjSessionFactory
+import dev.msfjarvis.aps.util.auth.BiometricAuthenticator
+import dev.msfjarvis.aps.data.repo.PasswordRepository
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import net.schmizz.sshj.common.DisconnectReason
+import net.schmizz.sshj.common.SSHException
+import net.schmizz.sshj.userauth.password.PasswordFinder
+import org.eclipse.jgit.api.Git
+import org.eclipse.jgit.api.GitCommand
+import org.eclipse.jgit.api.TransportCommand
+import org.eclipse.jgit.errors.UnsupportedCredentialItem
+import org.eclipse.jgit.transport.CredentialItem
+import org.eclipse.jgit.transport.CredentialsProvider
+import org.eclipse.jgit.transport.SshTransport
+import org.eclipse.jgit.transport.Transport
+import org.eclipse.jgit.transport.URIish
+
+/**
+ * Creates a new git operation
+ *
+ * @param callingActivity the calling activity
+ */
+abstract class GitOperation(protected val callingActivity: FragmentActivity) {
+
+ abstract val commands: Array<GitCommand<out Any>>
+ private val hostKeyFile = callingActivity.filesDir.resolve(".host_key")
+ private var sshSessionFactory: SshjSessionFactory? = null
+
+ protected val repository = PasswordRepository.getRepository(null)!!
+ protected val git = Git(repository)
+ protected val remoteBranch = GitSettings.branch
+ private val authActivity get() = callingActivity as ContinuationContainerActivity
+
+ private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) : CredentialsProvider() {
+
+ private var cachedPassword: CharArray? = null
+
+ override fun isInteractive() = true
+
+ override fun get(uri: URIish?, vararg items: CredentialItem): Boolean {
+ for (item in items) {
+ when (item) {
+ is CredentialItem.Username -> item.value = uri?.user
+ is CredentialItem.Password -> {
+ item.value = cachedPassword?.clone()
+ ?: passwordFinder.reqPassword(null).also {
+ cachedPassword = it.clone()
+ }
+ }
+ else -> UnsupportedCredentialItem(uri, item.javaClass.name)
+ }
+ }
+ return true
+ }
+
+ override fun supports(vararg items: CredentialItem) = items.all {
+ it is CredentialItem.Username || it is CredentialItem.Password
+ }
+
+ override fun reset(uri: URIish?) {
+ cachedPassword?.fill(0.toChar())
+ cachedPassword = null
+ }
+ }
+
+ private fun getSshKey(make: Boolean) {
+ runCatching {
+ // Ask the UserPreference to provide us with the ssh-key
+ val intent = Intent(callingActivity.applicationContext, UserPreference::class.java)
+ intent.putExtra("operation", if (make) "make_ssh_key" else "get_ssh_key")
+ callingActivity.startActivity(intent)
+ }.onFailure { e ->
+ e(e)
+ }
+ }
+
+ private fun registerAuthProviders(authMethod: SshAuthMethod, credentialsProvider: CredentialsProvider? = null) {
+ sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile)
+ commands.filterIsInstance<TransportCommand<*, *>>().forEach { command ->
+ command.setTransportConfigCallback { transport: Transport ->
+ (transport as? SshTransport)?.sshSessionFactory = sshSessionFactory
+ credentialsProvider?.let { transport.credentialsProvider = it }
+ }
+ command.setTimeout(CONNECT_TIMEOUT)
+ }
+ }
+
+ /**
+ * Executes the GitCommand in an async task.
+ */
+ suspend fun execute(): Result<Unit, Throwable> {
+ if (!preExecute()) {
+ return Ok(Unit)
+ }
+ val operationResult = GitCommandExecutor(
+ callingActivity,
+ this,
+ ).execute()
+ postExecute()
+ return operationResult
+ }
+
+ private fun onMissingSshKeyFile() {
+ MaterialAlertDialogBuilder(callingActivity)
+ .setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text))
+ .setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title))
+ .setPositiveButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_import)) { _, _ ->
+ getSshKey(false)
+ }
+ .setNegativeButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate)) { _, _ ->
+ getSshKey(true)
+ }
+ .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ ->
+ // Finish the blank GitActivity so user doesn't have to press back
+ callingActivity.finish()
+ }.show()
+ }
+
+ suspend fun executeAfterAuthentication(authMode: AuthMode): Result<Unit, Throwable> {
+ when (authMode) {
+ AuthMode.SshKey -> if (SshKey.exists) {
+ if (SshKey.mustAuthenticate) {
+ val result = withContext(Dispatchers.Main) {
+ suspendCoroutine<BiometricAuthenticator.Result> { cont ->
+ BiometricAuthenticator.authenticate(callingActivity, R.string.biometric_prompt_title_ssh_auth) {
+ if (it !is BiometricAuthenticator.Result.Failure)
+ cont.resume(it)
+ }
+ }
+ }
+ when (result) {
+ is BiometricAuthenticator.Result.Success -> {
+ registerAuthProviders(SshAuthMethod.SshKey(authActivity))
+ }
+ is BiometricAuthenticator.Result.Cancelled -> {
+ return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER))
+ }
+ is BiometricAuthenticator.Result.Failure -> {
+ throw IllegalStateException("Biometric authentication failures should be ignored")
+ }
+ else -> {
+ // There is a chance we succeed if the user recently confirmed
+ // their screen lock. Doing so would have a potential to confuse
+ // users though, who might deduce that the screen lock
+ // protection is not effective. Hence, we fail with an error.
+ Toast.makeText(callingActivity.applicationContext, R.string.biometric_auth_generic_failure, Toast.LENGTH_LONG).show()
+ callingActivity.finish()
+ }
+ }
+ } else {
+ registerAuthProviders(SshAuthMethod.SshKey(authActivity))
+ }
+ } else {
+ onMissingSshKeyFile()
+ // This would correctly cancel the operation but won't surface a user-visible
+ // error, allowing users to make the SSH key selection.
+ return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER))
+ }
+ AuthMode.OpenKeychain -> registerAuthProviders(SshAuthMethod.OpenKeychain(authActivity))
+ AuthMode.Password -> {
+ val httpsCredentialProvider = HttpsCredentialsProvider(CredentialFinder(callingActivity, AuthMode.Password))
+ registerAuthProviders(SshAuthMethod.Password(authActivity), httpsCredentialProvider)
+ }
+ AuthMode.None -> {
+ }
+ }
+ return execute()
+ }
+
+ /**
+ * Called before execution of the Git operation.
+ * Return false to cancel.
+ */
+ open fun preExecute() = true
+
+ private suspend fun postExecute() {
+ withContext(Dispatchers.IO) {
+ sshSessionFactory?.close()
+ }
+ }
+
+ companion object {
+
+ /**
+ * Timeout in seconds before [TransportCommand] will abort a stalled IO operation.
+ */
+ private const val CONNECT_TIMEOUT = 10
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt
new file mode 100644
index 00000000..7bee775a
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.git.operation
+
+import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
+import org.eclipse.jgit.api.GitCommand
+
+class PullOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
+
+ /**
+ * The story of why the pull operation is committing files goes like this: Once upon a time when
+ * the world was burning and Blade Runner 2049 was real life (in the worst way), we were made
+ * aware that Bitbucket is actually bad, and disables a neat OpenSSH feature called multiplexing.
+ * So now, rather than being able to do a [SyncOperation], we'd have to first do a [PullOperation]
+ * and then a [PushOperation]. To make the behavior identical despite this suboptimal situation,
+ * we opted to replicate [SyncOperation]'s committing flow within [PullOperation], almost exactly
+ * replicating [SyncOperation] but leaving the pushing part to [PushOperation].
+ */
+ override val commands: Array<GitCommand<out Any>> = arrayOf(
+ // Stage all files
+ git.add().addFilepattern("."),
+ // Populate the changed files count
+ git.status(),
+ // Commit everything! If needed, obviously.
+ git.commit().setAll(true).setMessage("[Android Password Store] Sync"),
+ // Pull and rebase on top of the remote branch
+ git.pull().setRebase(true).setRemote("origin"),
+ )
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt
new file mode 100644
index 00000000..31e5fcb7
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.git.operation
+
+import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
+import org.eclipse.jgit.api.GitCommand
+
+class PushOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
+
+ override val commands: Array<GitCommand<out Any>> = arrayOf(
+ git.push().setPushAll().setRemote("origin"),
+ )
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt
new file mode 100644
index 00000000..9c1fb01a
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.git.operation
+
+import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
+import org.eclipse.jgit.api.ResetCommand
+
+class ResetToRemoteOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
+
+ override val commands = arrayOf(
+ // Stage all files
+ git.add().addFilepattern("."),
+ // Fetch everything from the origin remote
+ git.fetch().setRemote("origin"),
+ // Do a hard reset to the remote branch. Equivalent to git reset --hard origin/$remoteBranch
+ git.reset().setRef("origin/$remoteBranch").setMode(ResetCommand.ResetType.HARD),
+ // Force-create $remoteBranch if it doesn't exist. This covers the case where you switched
+ // branches from 'master' to anything else.
+ git.branchCreate().setName(remoteBranch).setForce(true),
+ )
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt
new file mode 100644
index 00000000..512d6b48
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.git.operation
+
+import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
+
+class SyncOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
+
+ override val commands = arrayOf(
+ // Stage all files
+ git.add().addFilepattern("."),
+ // Populate the changed files count
+ git.status(),
+ // Commit everything! If needed, obviously.
+ git.commit().setAll(true).setMessage("[Android Password Store] Sync"),
+ // Pull and rebase on top of the remote branch
+ git.pull().setRebase(true).setRemote("origin"),
+ // Push it all back
+ git.push().setPushAll().setRemote("origin"),
+ )
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt
new file mode 100644
index 00000000..8bcdad05
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.git.sshj
+
+import android.content.Intent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.LayoutRes
+import androidx.appcompat.app.AppCompatActivity
+import kotlin.coroutines.Continuation
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import net.schmizz.sshj.common.DisconnectReason
+import net.schmizz.sshj.userauth.UserAuthException
+
+/**
+ * Workaround for https://msfjarvis.dev/aps/issue/1164
+ */
+open class ContinuationContainerActivity : AppCompatActivity {
+
+ constructor() : super()
+ constructor(@LayoutRes layoutRes: Int) : super(layoutRes)
+
+ var stashedCont: Continuation<Intent>? = null
+
+ val continueAfterUserInteraction = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result ->
+ stashedCont?.let { cont ->
+ stashedCont = null
+ val data = result.data
+ if (data != null)
+ cont.resume(data)
+ else
+ cont.resumeWithException(UserAuthException(DisconnectReason.AUTH_CANCELLED_BY_USER))
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt
new file mode 100644
index 00000000..3b0b2549
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt
@@ -0,0 +1,191 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.git.sshj
+
+import android.app.PendingIntent
+import android.content.Intent
+import androidx.activity.result.IntentSenderRequest
+import androidx.core.content.edit
+import com.github.ajalt.timberkt.d
+import dev.msfjarvis.aps.util.extensions.OPENPGP_PROVIDER
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.extensions.sharedPrefs
+import java.io.Closeable
+import java.security.PublicKey
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import net.schmizz.sshj.common.DisconnectReason
+import net.schmizz.sshj.common.KeyType
+import net.schmizz.sshj.userauth.UserAuthException
+import net.schmizz.sshj.userauth.keyprovider.KeyProvider
+import org.openintents.ssh.authentication.ISshAuthenticationService
+import org.openintents.ssh.authentication.SshAuthenticationApi
+import org.openintents.ssh.authentication.SshAuthenticationApiError
+import org.openintents.ssh.authentication.SshAuthenticationConnection
+import org.openintents.ssh.authentication.request.KeySelectionRequest
+import org.openintents.ssh.authentication.request.Request
+import org.openintents.ssh.authentication.request.SigningRequest
+import org.openintents.ssh.authentication.request.SshPublicKeyRequest
+import org.openintents.ssh.authentication.response.KeySelectionResponse
+import org.openintents.ssh.authentication.response.Response
+import org.openintents.ssh.authentication.response.SigningResponse
+import org.openintents.ssh.authentication.response.SshPublicKeyResponse
+
+class OpenKeychainKeyProvider private constructor(val activity: ContinuationContainerActivity) : KeyProvider, Closeable {
+
+ companion object {
+
+ suspend fun prepareAndUse(activity: ContinuationContainerActivity, block: (provider: OpenKeychainKeyProvider) -> Unit) {
+ withContext(Dispatchers.Main) {
+ OpenKeychainKeyProvider(activity)
+ }.prepareAndUse(block)
+ }
+ }
+
+ private sealed class ApiResponse {
+ data class Success(val response: Response) : ApiResponse()
+ data class GeneralError(val exception: Exception) : ApiResponse()
+ data class NoSuchKey(val exception: Exception) : ApiResponse()
+ }
+
+ private val context = activity.applicationContext
+ private val sshServiceConnection = SshAuthenticationConnection(context, OPENPGP_PROVIDER)
+ private val preferences = context.sharedPrefs
+ private lateinit var sshServiceApi: SshAuthenticationApi
+
+ private var keyId
+ get() = preferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null)
+ set(value) {
+ preferences.edit {
+ putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, value)
+ }
+ }
+ private var publicKey: PublicKey? = null
+ private var privateKey: OpenKeychainPrivateKey? = null
+
+ private suspend fun prepareAndUse(block: (provider: OpenKeychainKeyProvider) -> Unit) {
+ prepare()
+ use(block)
+ }
+
+ private suspend fun prepare() {
+ sshServiceApi = suspendCoroutine { cont ->
+ sshServiceConnection.connect(object : SshAuthenticationConnection.OnBound {
+ override fun onBound(sshAgent: ISshAuthenticationService) {
+ d { "Bound to SshAuthenticationApi: $OPENPGP_PROVIDER" }
+ cont.resume(SshAuthenticationApi(context, sshAgent))
+ }
+
+ override fun onError() {
+ throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable")
+ }
+ })
+ }
+
+ if (keyId == null) {
+ selectKey()
+ }
+ check(keyId != null)
+ fetchPublicKey()
+ makePrivateKey()
+ }
+
+ private suspend fun fetchPublicKey(isRetry: Boolean = false) {
+ when (val sshPublicKeyResponse = executeApiRequest(SshPublicKeyRequest(keyId))) {
+ is ApiResponse.Success -> {
+ val response = sshPublicKeyResponse.response as SshPublicKeyResponse
+ val sshPublicKey = response.sshPublicKey!!
+ publicKey = parseSshPublicKey(sshPublicKey)
+ ?: throw IllegalStateException("OpenKeychain API returned invalid SSH key")
+ }
+ is ApiResponse.NoSuchKey -> if (isRetry) {
+ throw sshPublicKeyResponse.exception
+ } else {
+ // Allow the user to reselect an authentication key and retry
+ selectKey()
+ fetchPublicKey(true)
+ }
+ is ApiResponse.GeneralError -> throw sshPublicKeyResponse.exception
+ }
+ }
+
+ private suspend fun selectKey() {
+ when (val keySelectionResponse = executeApiRequest(KeySelectionRequest())) {
+ is ApiResponse.Success -> keyId = (keySelectionResponse.response as KeySelectionResponse).keyId
+ is ApiResponse.GeneralError -> throw keySelectionResponse.exception
+ is ApiResponse.NoSuchKey -> throw keySelectionResponse.exception
+ }
+ }
+
+ private suspend fun executeApiRequest(request: Request, resultOfUserInteraction: Intent? = null): ApiResponse {
+ d { "executeRequest($request) called" }
+ val result = withContext(Dispatchers.Main) {
+ // If the request required user interaction, the data returned from the PendingIntent
+ // is used as the real request.
+ sshServiceApi.executeApi(resultOfUserInteraction ?: request.toIntent())!!
+ }
+ return parseResult(request, result).also {
+ d { "executeRequest($request): $it" }
+ }
+ }
+
+ private suspend fun parseResult(request: Request, result: Intent): ApiResponse {
+ return when (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, SshAuthenticationApi.RESULT_CODE_ERROR)) {
+ SshAuthenticationApi.RESULT_CODE_SUCCESS -> {
+ ApiResponse.Success(when (request) {
+ is KeySelectionRequest -> KeySelectionResponse(result)
+ is SshPublicKeyRequest -> SshPublicKeyResponse(result)
+ is SigningRequest -> SigningResponse(result)
+ else -> throw IllegalArgumentException("Unsupported OpenKeychain request type")
+ })
+ }
+ SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
+ val pendingIntent: PendingIntent = result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!!
+ val resultOfUserInteraction: Intent = withContext(Dispatchers.Main) {
+ suspendCoroutine { cont ->
+ activity.stashedCont = cont
+ activity.continueAfterUserInteraction.launch(IntentSenderRequest.Builder(pendingIntent).build())
+ }
+ }
+ executeApiRequest(request, resultOfUserInteraction)
+ }
+ else -> {
+ val error = result.getParcelableExtra<SshAuthenticationApiError>(SshAuthenticationApi.EXTRA_ERROR)
+ val exception = UserAuthException(DisconnectReason.UNKNOWN, "Request ${request::class.simpleName} failed: ${error?.message}")
+ when (error?.error) {
+ SshAuthenticationApiError.NO_AUTH_KEY, SshAuthenticationApiError.NO_SUCH_KEY -> ApiResponse.NoSuchKey(exception)
+ else -> ApiResponse.GeneralError(exception)
+ }
+ }
+ }
+ }
+
+ private fun makePrivateKey() {
+ check(keyId != null && publicKey != null)
+ privateKey = object : OpenKeychainPrivateKey {
+ override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) =
+ when (val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))) {
+ is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature
+ is ApiResponse.GeneralError -> throw signingResponse.exception
+ is ApiResponse.NoSuchKey -> throw signingResponse.exception
+ }
+
+ override fun getAlgorithm() = publicKey!!.algorithm
+ }
+ }
+
+ override fun close() {
+ activity.continueAfterUserInteraction.unregister()
+ sshServiceConnection.disconnect()
+ }
+
+ override fun getPrivate() = privateKey
+
+ override fun getPublic() = publicKey
+
+ override fun getType() = KeyType.fromKey(publicKey)
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt
new file mode 100644
index 00000000..272a04dd
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.git.sshj
+
+import com.hierynomus.sshj.key.KeyAlgorithm
+import java.io.ByteArrayOutputStream
+import java.security.PrivateKey
+import kotlinx.coroutines.runBlocking
+import net.schmizz.sshj.common.Buffer
+import net.schmizz.sshj.common.Factory
+import net.schmizz.sshj.signature.Signature
+import org.openintents.ssh.authentication.SshAuthenticationApi
+
+interface OpenKeychainPrivateKey : PrivateKey {
+
+ suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray
+
+ override fun getFormat() = null
+ override fun getEncoded() = null
+}
+
+class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named<KeyAlgorithm>) : Factory.Named<KeyAlgorithm> by factory {
+
+ override fun create() = OpenKeychainWrappedKeyAlgorithm(factory.create())
+}
+
+class OpenKeychainWrappedKeyAlgorithm(private val keyAlgorithm: KeyAlgorithm) : KeyAlgorithm by keyAlgorithm {
+
+ private val hashAlgorithm = when (keyAlgorithm.keyAlgorithm) {
+ "rsa-sha2-512" -> SshAuthenticationApi.SHA512
+ "rsa-sha2-256" -> SshAuthenticationApi.SHA256
+ "ssh-rsa", "ssh-rsa-cert-v01@openssh.com" -> SshAuthenticationApi.SHA1
+ // Other algorithms don't use this value, but it has to be valid.
+ else -> SshAuthenticationApi.SHA512
+ }
+
+ override fun newSignature() = OpenKeychainWrappedSignature(keyAlgorithm.newSignature(), hashAlgorithm)
+}
+
+class OpenKeychainWrappedSignature(private val wrappedSignature: Signature, private val hashAlgorithm: Int) : Signature by wrappedSignature {
+
+ private val data = ByteArrayOutputStream()
+
+ private var bridgedPrivateKey: OpenKeychainPrivateKey? = null
+
+ override fun initSign(prvkey: PrivateKey?) {
+ if (prvkey is OpenKeychainPrivateKey) {
+ bridgedPrivateKey = prvkey
+ } else {
+ wrappedSignature.initSign(prvkey)
+ }
+ }
+
+ override fun update(H: ByteArray?) {
+ if (bridgedPrivateKey != null) {
+ data.write(H!!)
+ } else {
+ wrappedSignature.update(H)
+ }
+ }
+
+ override fun update(H: ByteArray?, off: Int, len: Int) {
+ if (bridgedPrivateKey != null) {
+ data.write(H!!, off, len)
+ } else {
+ wrappedSignature.update(H, off, len)
+ }
+ }
+
+ override fun sign(): ByteArray? = if (bridgedPrivateKey != null) {
+ runBlocking {
+ bridgedPrivateKey!!.sign(data.toByteArray(), hashAlgorithm)
+ }
+ } else {
+ wrappedSignature.sign()
+ }
+
+ override fun encode(signature: ByteArray?): ByteArray? = if (bridgedPrivateKey != null) {
+ require(signature != null) { "OpenKeychain signature must not be null" }
+ val encodedSignature = Buffer.PlainBuffer(signature)
+ // We need to drop the algorithm name and extract the raw signature since SSHJ adds the name
+ // later.
+ encodedSignature.readString()
+ encodedSignature.readBytes().also {
+ bridgedPrivateKey = null
+ data.reset()
+ }
+ } else {
+ wrappedSignature.encode(signature)
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt
new file mode 100644
index 00000000..352465e0
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt
@@ -0,0 +1,336 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.git.sshj
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.provider.OpenableColumns
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyInfo
+import android.security.keystore.KeyProperties
+import android.util.Base64
+import androidx.core.content.edit
+import androidx.security.crypto.EncryptedFile
+import androidx.security.crypto.MasterKey
+import com.github.ajalt.timberkt.d
+import com.github.ajalt.timberkt.e
+import com.github.michaelbull.result.getOrElse
+import com.github.michaelbull.result.runCatching
+import dev.msfjarvis.aps.Application
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs
+import dev.msfjarvis.aps.util.extensions.getString
+import dev.msfjarvis.aps.util.extensions.sharedPrefs
+import java.io.File
+import java.io.IOException
+import java.security.KeyFactory
+import java.security.KeyPairGenerator
+import java.security.KeyStore
+import java.security.PrivateKey
+import java.security.PublicKey
+import javax.crypto.SecretKey
+import javax.crypto.SecretKeyFactory
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import net.i2p.crypto.eddsa.EdDSAPrivateKey
+import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable
+import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec
+import net.schmizz.sshj.SSHClient
+import net.schmizz.sshj.common.Buffer
+import net.schmizz.sshj.common.KeyType
+import net.schmizz.sshj.userauth.keyprovider.KeyProvider
+
+private const val PROVIDER_ANDROID_KEY_STORE = "AndroidKeyStore"
+private const val KEYSTORE_ALIAS = "sshkey"
+private const val ANDROIDX_SECURITY_KEYSET_PREF_NAME = "androidx_sshkey_keyset_prefs"
+
+private val androidKeystore: KeyStore by lazy(LazyThreadSafetyMode.NONE) {
+ KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) }
+}
+
+private val KeyStore.sshPrivateKey
+ get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey
+
+private val KeyStore.sshPublicKey
+ get() = getCertificate(KEYSTORE_ALIAS)?.publicKey
+
+fun parseSshPublicKey(sshPublicKey: String): PublicKey? {
+ val sshKeyParts = sshPublicKey.split("""\s+""".toRegex())
+ if (sshKeyParts.size < 2)
+ return null
+ return Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey()
+}
+
+fun toSshPublicKey(publicKey: PublicKey): String {
+ val rawPublicKey = Buffer.PlainBuffer().putPublicKey(publicKey).compactData
+ val keyType = KeyType.fromKey(publicKey)
+ return "$keyType ${Base64.encodeToString(rawPublicKey, Base64.NO_WRAP)}"
+}
+
+object SshKey {
+
+ val sshPublicKey
+ get() = if (publicKeyFile.exists()) publicKeyFile.readText() else null
+ val canShowSshPublicKey
+ get() = type in listOf(Type.LegacyGenerated, Type.KeystoreNative, Type.KeystoreWrappedEd25519)
+ val exists
+ get() = type != null
+ val mustAuthenticate: Boolean
+ get() {
+ return runCatching {
+ if (type !in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519))
+ return false
+ when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) {
+ is PrivateKey -> {
+ val factory = KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
+ return factory.getKeySpec(key, KeyInfo::class.java).isUserAuthenticationRequired
+ }
+ is SecretKey -> {
+ val factory = SecretKeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
+ (factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo).isUserAuthenticationRequired
+ }
+ else -> throw IllegalStateException("SSH key does not exist in Keystore")
+ }
+ }.getOrElse { error ->
+ // It is fine to swallow the exception here since it will reappear when the key is
+ // used for SSH authentication and can then be shown in the UI.
+ d(error)
+ false
+ }
+ }
+
+ private val context: Context
+ get() = Application.instance.applicationContext
+
+ private val privateKeyFile
+ get() = File(context.filesDir, ".ssh_key")
+ private val publicKeyFile
+ get() = File(context.filesDir, ".ssh_key.pub")
+
+ private var type: Type?
+ get() = Type.fromValue(context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_KEY_TYPE))
+ set(value) = context.sharedPrefs.edit {
+ putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value)
+ }
+
+ private val isStrongBoxSupported by lazy(LazyThreadSafetyMode.NONE) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
+ context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
+ else
+ false
+ }
+
+ private enum class Type(val value: String) {
+ Imported("imported"),
+ KeystoreNative("keystore_native"),
+ KeystoreWrappedEd25519("keystore_wrapped_ed25519"),
+
+ // Behaves like `Imported`, but allows to view the public key.
+ LegacyGenerated("legacy_generated"),
+ ;
+
+ companion object {
+
+ fun fromValue(value: String?): Type? = values().associateBy { it.value }[value]
+ }
+ }
+
+ enum class Algorithm(val algorithm: String, val applyToSpec: KeyGenParameterSpec.Builder.() -> Unit) {
+ Rsa(KeyProperties.KEY_ALGORITHM_RSA, {
+ setKeySize(3072)
+ setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
+ setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
+ }),
+ Ecdsa(KeyProperties.KEY_ALGORITHM_EC, {
+ setKeySize(256)
+ setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1"))
+ setDigests(KeyProperties.DIGEST_SHA256)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ setIsStrongBoxBacked(isStrongBoxSupported)
+ }
+ }),
+ }
+
+ private fun delete() {
+ androidKeystore.deleteEntry(KEYSTORE_ALIAS)
+ // Remove Tink key set used by AndroidX's EncryptedFile.
+ context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE).edit {
+ clear()
+ }
+ if (privateKeyFile.isFile) {
+ privateKeyFile.delete()
+ }
+ if (publicKeyFile.isFile) {
+ publicKeyFile.delete()
+ }
+ context.getEncryptedGitPrefs().edit {
+ remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE)
+ }
+ type = null
+ }
+
+ fun import(uri: Uri) {
+ // First check whether the content at uri is likely an SSH private key.
+ val fileSize = context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)
+ ?.use { cursor ->
+ // Cursor returns only a single row.
+ cursor.moveToFirst()
+ cursor.getInt(0)
+ } ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
+
+ // We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes.
+ if (fileSize > 100_000 || fileSize == 0)
+ throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message))
+
+ val sshKeyInputStream = context.contentResolver.openInputStream(uri)
+ ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
+ val lines = sshKeyInputStream.bufferedReader().readLines()
+
+ // The file must have more than 2 lines, and the first and last line must have private key
+ // markers.
+ if (lines.size < 2 ||
+ !Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) ||
+ !Regex("END .* PRIVATE KEY").containsMatchIn(lines.last())
+ )
+ throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message))
+
+ // At this point, we are reasonably confident that we have actually been provided a private
+ // key and delete the old key.
+ delete()
+ // Canonicalize line endings to '\n'.
+ privateKeyFile.writeText(lines.joinToString("\n"))
+
+ type = Type.Imported
+ }
+
+ @Deprecated("To be used only in Migrations.kt")
+ fun useLegacyKey(isGenerated: Boolean) {
+ type = if (isGenerated) Type.LegacyGenerated else Type.Imported
+ }
+
+ @Suppress("BlockingMethodInNonBlockingContext")
+ private suspend fun getOrCreateWrappingMasterKey(requireAuthentication: Boolean) = withContext(Dispatchers.IO) {
+ MasterKey.Builder(context, KEYSTORE_ALIAS).run {
+ setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ setRequestStrongBoxBacked(true)
+ setUserAuthenticationRequired(requireAuthentication, 15)
+ build()
+ }
+ }
+
+ @Suppress("BlockingMethodInNonBlockingContext")
+ private suspend fun getOrCreateWrappedPrivateKeyFile(requireAuthentication: Boolean) = withContext(Dispatchers.IO) {
+ EncryptedFile.Builder(context,
+ privateKeyFile,
+ getOrCreateWrappingMasterKey(requireAuthentication),
+ EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB).run {
+ setKeysetPrefName(ANDROIDX_SECURITY_KEYSET_PREF_NAME)
+ build()
+ }
+ }
+
+ @Suppress("BlockingMethodInNonBlockingContext")
+ suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) = withContext(Dispatchers.IO) {
+ delete()
+
+ val encryptedPrivateKeyFile = getOrCreateWrappedPrivateKeyFile(requireAuthentication)
+ // Generate the ed25519 key pair and encrypt the private key.
+ val keyPair = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair()
+ encryptedPrivateKeyFile.openFileOutput().use { os ->
+ os.write((keyPair.private as EdDSAPrivateKey).seed)
+ }
+
+ // Write public key in SSH format to .ssh_key.pub.
+ publicKeyFile.writeText(toSshPublicKey(keyPair.public))
+
+ type = Type.KeystoreWrappedEd25519
+ }
+
+ fun generateKeystoreNativeKey(algorithm: Algorithm, requireAuthentication: Boolean) {
+ delete()
+
+ // Generate Keystore-backed private key.
+ val parameterSpec = KeyGenParameterSpec.Builder(
+ KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN
+ ).run {
+ apply(algorithm.applyToSpec)
+ if (requireAuthentication) {
+ setUserAuthenticationRequired(true)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ setUserAuthenticationParameters(30, KeyProperties.AUTH_DEVICE_CREDENTIAL)
+ } else {
+ @Suppress("DEPRECATION")
+ setUserAuthenticationValidityDurationSeconds(30)
+ }
+ }
+ build()
+ }
+ val keyPair = KeyPairGenerator.getInstance(algorithm.algorithm, PROVIDER_ANDROID_KEY_STORE).run {
+ initialize(parameterSpec)
+ generateKeyPair()
+ }
+
+ // Write public key in SSH format to .ssh_key.pub.
+ publicKeyFile.writeText(toSshPublicKey(keyPair.public))
+
+ type = Type.KeystoreNative
+ }
+
+ fun provide(client: SSHClient, passphraseFinder: InteractivePasswordFinder): KeyProvider? = when (type) {
+ Type.LegacyGenerated, Type.Imported -> client.loadKeys(privateKeyFile.absolutePath, passphraseFinder)
+ Type.KeystoreNative -> KeystoreNativeKeyProvider
+ Type.KeystoreWrappedEd25519 -> KeystoreWrappedEd25519KeyProvider
+ null -> null
+ }
+
+ private object KeystoreNativeKeyProvider : KeyProvider {
+
+ override fun getPublic(): PublicKey = runCatching {
+ androidKeystore.sshPublicKey!!
+ }.getOrElse { error ->
+ e(error)
+ throw IOException("Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", error)
+ }
+
+ override fun getPrivate(): PrivateKey = runCatching {
+ androidKeystore.sshPrivateKey!!
+ }.getOrElse { error ->
+ e(error)
+ throw IOException("Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore", error)
+ }
+
+ override fun getType(): KeyType = KeyType.fromKey(public)
+ }
+
+ private object KeystoreWrappedEd25519KeyProvider : KeyProvider {
+
+ override fun getPublic(): PublicKey = runCatching {
+ parseSshPublicKey(sshPublicKey!!)!!
+ }.getOrElse { error ->
+ e(error)
+ throw IOException("Failed to get the public key for wrapped ed25519 key", error)
+ }
+
+ override fun getPrivate(): PrivateKey = runCatching {
+ // The current MasterKey API does not allow getting a reference to an existing one
+ // without specifying the KeySpec for a new one. However, the value for passed here
+ // for `requireAuthentication` is not used as the key already exists at this point.
+ val encryptedPrivateKeyFile = runBlocking {
+ getOrCreateWrappedPrivateKeyFile(false)
+ }
+ val rawPrivateKey = encryptedPrivateKeyFile.openFileInput().use { it.readBytes() }
+ EdDSAPrivateKey(EdDSAPrivateKeySpec(rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC))
+ }.getOrElse { error ->
+ e(error)
+ throw IOException("Failed to unwrap wrapped ed25519 key", error)
+ }
+
+ override fun getType(): KeyType = KeyType.fromKey(public)
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt
new file mode 100644
index 00000000..8402d232
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt
@@ -0,0 +1,275 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.git.sshj
+
+import com.github.ajalt.timberkt.Timber
+import com.github.ajalt.timberkt.d
+import com.github.michaelbull.result.runCatching
+import com.hierynomus.sshj.key.KeyAlgorithms
+import com.hierynomus.sshj.transport.cipher.BlockCiphers
+import com.hierynomus.sshj.transport.kex.ExtInfoClientFactory
+import com.hierynomus.sshj.transport.mac.Macs
+import com.hierynomus.sshj.userauth.keyprovider.OpenSSHKeyV1KeyFile
+import java.security.Security
+import net.schmizz.keepalive.KeepAliveProvider
+import net.schmizz.sshj.ConfigImpl
+import net.schmizz.sshj.common.LoggerFactory
+import net.schmizz.sshj.common.SecurityUtils
+import net.schmizz.sshj.transport.compression.NoneCompression
+import net.schmizz.sshj.transport.kex.Curve25519SHA256
+import net.schmizz.sshj.transport.kex.Curve25519SHA256.FactoryLibSsh
+import net.schmizz.sshj.transport.kex.DHGexSHA256
+import net.schmizz.sshj.transport.random.JCERandom
+import net.schmizz.sshj.transport.random.SingletonRandomFactory
+import net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile
+import net.schmizz.sshj.userauth.keyprovider.PKCS5KeyFile
+import net.schmizz.sshj.userauth.keyprovider.PKCS8KeyFile
+import net.schmizz.sshj.userauth.keyprovider.PuTTYKeyFile
+import org.bouncycastle.jce.provider.BouncyCastleProvider
+import org.slf4j.Logger
+import org.slf4j.Marker
+
+
+fun setUpBouncyCastleForSshj() {
+ // Replace the Android BC provider with the Java BouncyCastle provider since the former does
+ // not include all the required algorithms.
+ // Note: This may affect crypto operations in other parts of the application.
+ val bcIndex = Security.getProviders().indexOfFirst {
+ it.name == BouncyCastleProvider.PROVIDER_NAME
+ }
+ if (bcIndex == -1) {
+ // No Android BC found, install Java BC at lowest priority.
+ Security.addProvider(BouncyCastleProvider())
+ } else {
+ // Replace Android BC with Java BC, inserted at the same position.
+ Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME)
+ // May be needed on Android Pie+ as per https://stackoverflow.com/a/57897224/297261
+ runCatching { Class.forName("sun.security.jca.Providers") }
+ Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1)
+ }
+ d { "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" }
+ // Prevent sshj from forwarding all cryptographic operations to BC.
+ SecurityUtils.setRegisterBouncyCastle(false)
+ SecurityUtils.setSecurityProvider(null)
+}
+
+private abstract class AbstractLogger(private val name: String) : Logger {
+
+ abstract fun t(message: String, t: Throwable? = null, vararg args: Any?)
+ abstract fun d(message: String, t: Throwable? = null, vararg args: Any?)
+ abstract fun i(message: String, t: Throwable? = null, vararg args: Any?)
+ abstract fun w(message: String, t: Throwable? = null, vararg args: Any?)
+ abstract fun e(message: String, t: Throwable? = null, vararg args: Any?)
+
+ override fun getName() = name
+
+ override fun isTraceEnabled(marker: Marker?): Boolean = isTraceEnabled
+ override fun isDebugEnabled(marker: Marker?): Boolean = isDebugEnabled
+ override fun isInfoEnabled(marker: Marker?): Boolean = isInfoEnabled
+ override fun isWarnEnabled(marker: Marker?): Boolean = isWarnEnabled
+ override fun isErrorEnabled(marker: Marker?): Boolean = isErrorEnabled
+
+ override fun trace(msg: String) = t(msg)
+ override fun trace(format: String, arg: Any?) = t(format, null, arg)
+ override fun trace(format: String, arg1: Any?, arg2: Any?) = t(format, null, arg1, arg2)
+ override fun trace(format: String, vararg arguments: Any?) = t(format, null, *arguments)
+ override fun trace(msg: String, t: Throwable?) = t(msg, t)
+ override fun trace(marker: Marker, msg: String) = trace(msg)
+ override fun trace(marker: Marker?, format: String, arg: Any?) = trace(format, arg)
+ override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
+ trace(format, arg1, arg2)
+
+ override fun trace(marker: Marker?, format: String, vararg arguments: Any?) =
+ trace(format, *arguments)
+
+ override fun trace(marker: Marker?, msg: String, t: Throwable?) = trace(msg, t)
+
+ override fun debug(msg: String) = d(msg)
+ override fun debug(format: String, arg: Any?) = d(format, null, arg)
+ override fun debug(format: String, arg1: Any?, arg2: Any?) = d(format, null, arg1, arg2)
+ override fun debug(format: String, vararg arguments: Any?) = d(format, null, *arguments)
+ override fun debug(msg: String, t: Throwable?) = d(msg, t)
+ override fun debug(marker: Marker, msg: String) = debug(msg)
+ override fun debug(marker: Marker?, format: String, arg: Any?) = debug(format, arg)
+ override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
+ debug(format, arg1, arg2)
+
+ override fun debug(marker: Marker?, format: String, vararg arguments: Any?) =
+ debug(format, *arguments)
+
+ override fun debug(marker: Marker?, msg: String, t: Throwable?) = debug(msg, t)
+
+ override fun info(msg: String) = i(msg)
+ override fun info(format: String, arg: Any?) = i(format, null, arg)
+ override fun info(format: String, arg1: Any?, arg2: Any?) = i(format, null, arg1, arg2)
+ override fun info(format: String, vararg arguments: Any?) = i(format, null, *arguments)
+ override fun info(msg: String, t: Throwable?) = i(msg, t)
+ override fun info(marker: Marker, msg: String) = info(msg)
+ override fun info(marker: Marker?, format: String, arg: Any?) = info(format, arg)
+ override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
+ info(format, arg1, arg2)
+
+ override fun info(marker: Marker?, format: String, vararg arguments: Any?) =
+ info(format, *arguments)
+
+ override fun info(marker: Marker?, msg: String, t: Throwable?) = info(msg, t)
+
+ override fun warn(msg: String) = w(msg)
+ override fun warn(format: String, arg: Any?) = w(format, null, arg)
+ override fun warn(format: String, arg1: Any?, arg2: Any?) = w(format, null, arg1, arg2)
+ override fun warn(format: String, vararg arguments: Any?) = w(format, null, *arguments)
+ override fun warn(msg: String, t: Throwable?) = w(msg, t)
+ override fun warn(marker: Marker, msg: String) = warn(msg)
+ override fun warn(marker: Marker?, format: String, arg: Any?) = warn(format, arg)
+ override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
+ warn(format, arg1, arg2)
+
+ override fun warn(marker: Marker?, format: String, vararg arguments: Any?) =
+ warn(format, *arguments)
+
+ override fun warn(marker: Marker?, msg: String, t: Throwable?) = warn(msg, t)
+
+ override fun error(msg: String) = e(msg)
+ override fun error(format: String, arg: Any?) = e(format, null, arg)
+ override fun error(format: String, arg1: Any?, arg2: Any?) = e(format, null, arg1, arg2)
+ override fun error(format: String, vararg arguments: Any?) = e(format, null, *arguments)
+ override fun error(msg: String, t: Throwable?) = e(msg, t)
+ override fun error(marker: Marker, msg: String) = error(msg)
+ override fun error(marker: Marker?, format: String, arg: Any?) = error(format, arg)
+ override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
+ error(format, arg1, arg2)
+
+ override fun error(marker: Marker?, format: String, vararg arguments: Any?) =
+ error(format, *arguments)
+
+ override fun error(marker: Marker?, msg: String, t: Throwable?) = error(msg, t)
+}
+
+object TimberLoggerFactory : LoggerFactory {
+ private class TimberLogger(name: String) : AbstractLogger(name) {
+
+ // We defer the log level checks to Timber.
+ override fun isTraceEnabled() = true
+ override fun isDebugEnabled() = true
+ override fun isInfoEnabled() = true
+ override fun isWarnEnabled() = true
+ override fun isErrorEnabled() = true
+
+ // Replace slf4j's "{}" format string style with standard Java's "%s".
+ // The supposedly redundant escape on the } is not redundant.
+ @Suppress("RegExpRedundantEscape")
+ private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s")
+
+ override fun t(message: String, t: Throwable?, vararg args: Any?) {
+ Timber.tag(name).v(t, message.fix(), *args)
+ }
+
+ override fun d(message: String, t: Throwable?, vararg args: Any?) {
+ Timber.tag(name).d(t, message.fix(), *args)
+ }
+
+ override fun i(message: String, t: Throwable?, vararg args: Any?) {
+ Timber.tag(name).i(t, message.fix(), *args)
+ }
+
+ override fun w(message: String, t: Throwable?, vararg args: Any?) {
+ Timber.tag(name).w(t, message.fix(), *args)
+ }
+
+ override fun e(message: String, t: Throwable?, vararg args: Any?) {
+ Timber.tag(name).e(t, message.fix(), *args)
+ }
+ }
+
+ override fun getLogger(name: String): Logger {
+ return TimberLogger(name)
+ }
+
+ override fun getLogger(clazz: Class<*>): Logger {
+ return TimberLogger(clazz.name)
+ }
+
+}
+
+class SshjConfig : ConfigImpl() {
+
+ init {
+ loggerFactory = TimberLoggerFactory
+ keepAliveProvider = KeepAliveProvider.HEARTBEAT
+ version = "OpenSSH_8.2p1 Ubuntu-4ubuntu0.1"
+
+ initKeyExchangeFactories()
+ initKeyAlgorithms()
+ initRandomFactory()
+ initFileKeyProviderFactories()
+ initCipherFactories()
+ initCompressionFactories()
+ initMACFactories()
+ }
+
+ private fun initKeyExchangeFactories() {
+ keyExchangeFactories = listOf(
+ Curve25519SHA256.Factory(),
+ FactoryLibSsh(),
+ DHGexSHA256.Factory(),
+ // Sends "ext-info-c" with the list of key exchange algorithms. This is needed to get
+ // rsa-sha2-* key types to work with some servers (e.g. GitHub).
+ ExtInfoClientFactory(),
+ )
+ }
+
+ private fun initKeyAlgorithms() {
+ keyAlgorithms = listOf(
+ KeyAlgorithms.SSHRSACertV01(),
+ KeyAlgorithms.EdDSA25519(),
+ KeyAlgorithms.RSASHA512(),
+ KeyAlgorithms.RSASHA256(),
+ KeyAlgorithms.SSHRSA(),
+ KeyAlgorithms.ECDSASHANistp521(),
+ KeyAlgorithms.ECDSASHANistp384(),
+ KeyAlgorithms.ECDSASHANistp256(),
+ ).map {
+ OpenKeychainWrappedKeyAlgorithmFactory(it)
+ }
+ }
+
+ private fun initRandomFactory() {
+ randomFactory = SingletonRandomFactory(JCERandom.Factory())
+ }
+
+ private fun initFileKeyProviderFactories() {
+ fileKeyProviderFactories = listOf(
+ OpenSSHKeyV1KeyFile.Factory(),
+ PKCS8KeyFile.Factory(),
+ PKCS5KeyFile.Factory(),
+ OpenSSHKeyFile.Factory(),
+ PuTTYKeyFile.Factory(),
+ )
+ }
+
+
+ private fun initCipherFactories() {
+ cipherFactories = listOf(
+ BlockCiphers.AES256CTR(),
+ BlockCiphers.AES192CTR(),
+ BlockCiphers.AES128CTR(),
+ )
+ }
+
+ private fun initMACFactories() {
+ macFactories = listOf(
+ Macs.HMACSHA2512Etm(),
+ Macs.HMACSHA2256Etm(),
+ Macs.HMACSHA2512(),
+ Macs.HMACSHA2256(),
+ )
+ }
+
+ private fun initCompressionFactories() {
+ compressionFactories = listOf(
+ NoneCompression.Factory(),
+ )
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt
new file mode 100644
index 00000000..7cd39653
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt
@@ -0,0 +1,197 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.git.sshj
+
+import android.util.Base64
+import com.github.ajalt.timberkt.d
+import com.github.ajalt.timberkt.w
+import com.github.michaelbull.result.getOrElse
+import com.github.michaelbull.result.runCatching
+import dev.msfjarvis.aps.util.settings.AuthMode
+import dev.msfjarvis.aps.util.git.operation.CredentialFinder
+import java.io.File
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import java.util.concurrent.TimeUnit
+import kotlin.coroutines.Continuation
+import kotlin.coroutines.suspendCoroutine
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import net.schmizz.sshj.SSHClient
+import net.schmizz.sshj.common.Buffer.PlainBuffer
+import net.schmizz.sshj.common.DisconnectReason
+import net.schmizz.sshj.common.SSHException
+import net.schmizz.sshj.common.SSHRuntimeException
+import net.schmizz.sshj.common.SecurityUtils
+import net.schmizz.sshj.connection.channel.direct.Session
+import net.schmizz.sshj.transport.verification.FingerprintVerifier
+import net.schmizz.sshj.transport.verification.HostKeyVerifier
+import net.schmizz.sshj.userauth.method.AuthPassword
+import net.schmizz.sshj.userauth.method.AuthPublickey
+import net.schmizz.sshj.userauth.password.PasswordFinder
+import net.schmizz.sshj.userauth.password.Resource
+import org.eclipse.jgit.transport.CredentialsProvider
+import org.eclipse.jgit.transport.RemoteSession
+import org.eclipse.jgit.transport.SshSessionFactory
+import org.eclipse.jgit.transport.URIish
+import org.eclipse.jgit.util.FS
+
+sealed class SshAuthMethod(val activity: ContinuationContainerActivity) {
+ class Password(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
+ class SshKey(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
+ class OpenKeychain(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
+}
+
+abstract class InteractivePasswordFinder : PasswordFinder {
+
+ private var isRetry = false
+
+ abstract fun askForPassword(cont: Continuation<String?>, isRetry: Boolean)
+
+ final override fun reqPassword(resource: Resource<*>?): CharArray {
+ val password = runBlocking(Dispatchers.Main) {
+ suspendCoroutine<String?> { cont ->
+ askForPassword(cont, isRetry)
+ }
+ }
+ isRetry = true
+ return password?.toCharArray()
+ ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)
+ }
+
+ final override fun shouldRetry(resource: Resource<*>?) = true
+}
+
+class SshjSessionFactory(private val authMethod: SshAuthMethod, private val hostKeyFile: File) : SshSessionFactory() {
+
+ private var currentSession: SshjSession? = null
+
+ override fun getSession(uri: URIish, credentialsProvider: CredentialsProvider?, fs: FS?, tms: Int): RemoteSession {
+ return currentSession
+ ?: SshjSession(uri, uri.user, authMethod, hostKeyFile).connect().also {
+ d { "New SSH connection created" }
+ currentSession = it
+ }
+ }
+
+ fun close() {
+ currentSession?.close()
+ }
+}
+
+private fun makeTofuHostKeyVerifier(hostKeyFile: File): HostKeyVerifier {
+ if (!hostKeyFile.exists()) {
+ return HostKeyVerifier { _, _, key ->
+ val digest = runCatching {
+ SecurityUtils.getMessageDigest("SHA-256")
+ }.getOrElse { e ->
+ throw SSHRuntimeException(e)
+ }
+ digest.update(PlainBuffer().putPublicKey(key).compactData)
+ val digestData = digest.digest()
+ val hostKeyEntry = "SHA256:${Base64.encodeToString(digestData, Base64.NO_WRAP)}"
+ d { "Trusting host key on first use: $hostKeyEntry" }
+ hostKeyFile.writeText(hostKeyEntry)
+ true
+ }
+ } else {
+ val hostKeyEntry = hostKeyFile.readText()
+ d { "Pinned host key: $hostKeyEntry" }
+ return FingerprintVerifier.getInstance(hostKeyEntry)
+ }
+}
+
+private class SshjSession(uri: URIish, private val username: String, private val authMethod: SshAuthMethod, private val hostKeyFile: File) : RemoteSession {
+
+ private lateinit var ssh: SSHClient
+ private var currentCommand: Session? = null
+
+ private val uri = if (uri.host.contains('@')) {
+ // URIish's String constructor cannot handle '@' in the user part of the URI and the URL
+ // constructor can't be used since Java's URL does not recognize the ssh scheme. We thus
+ // need to patch everything up ourselves.
+ d { "Before fixup: user=${uri.user}, host=${uri.host}" }
+ val userPlusHost = "${uri.user}@${uri.host}"
+ val realUser = userPlusHost.substringBeforeLast('@')
+ val realHost = userPlusHost.substringAfterLast('@')
+ uri.setUser(realUser).setHost(realHost).also { d { "After fixup: user=${it.user}, host=${it.host}" } }
+ } else {
+ uri
+ }
+
+ fun connect(): SshjSession {
+ ssh = SSHClient(SshjConfig())
+ ssh.addHostKeyVerifier(makeTofuHostKeyVerifier(hostKeyFile))
+ ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22)
+ if (!ssh.isConnected)
+ throw IOException()
+ val passwordAuth = AuthPassword(CredentialFinder(authMethod.activity, AuthMode.Password))
+ when (authMethod) {
+ is SshAuthMethod.Password -> {
+ ssh.auth(username, passwordAuth)
+ }
+ is SshAuthMethod.SshKey -> {
+ val pubkeyAuth = AuthPublickey(SshKey.provide(ssh, CredentialFinder(authMethod.activity, AuthMode.SshKey)))
+ ssh.auth(username, pubkeyAuth, passwordAuth)
+ }
+ is SshAuthMethod.OpenKeychain -> {
+ runBlocking {
+ OpenKeychainKeyProvider.prepareAndUse(authMethod.activity) { provider ->
+ val openKeychainAuth = AuthPublickey(provider)
+ ssh.auth(username, openKeychainAuth, passwordAuth)
+ }
+ }
+ }
+ }
+ return this
+ }
+
+ override fun exec(commandName: String?, timeout: Int): Process {
+ if (currentCommand != null) {
+ w { "Killing old command" }
+ disconnect()
+ }
+ val session = ssh.startSession()
+ currentCommand = session
+ return SshjProcess(session.exec(commandName), timeout.toLong())
+ }
+
+ /**
+ * Kills the current command if one is running and returns the session into a state where `exec`
+ * can be called.
+ *
+ * Note that this does *not* disconnect the session. Unfortunately, the function has to be
+ * called `disconnect` to override the corresponding abstract function in `RemoteSession`.
+ */
+ override fun disconnect() {
+ currentCommand?.close()
+ currentCommand = null
+ }
+
+ fun close() {
+ disconnect()
+ ssh.close()
+ }
+}
+
+private class SshjProcess(private val command: Session.Command, private val timeout: Long) : Process() {
+
+ override fun waitFor(): Int {
+ command.join(timeout, TimeUnit.SECONDS)
+ command.close()
+ return exitValue()
+ }
+
+ override fun destroy() = command.close()
+
+ override fun getOutputStream(): OutputStream = command.outputStream
+
+ override fun getErrorStream(): InputStream = command.errorStream
+
+ override fun exitValue(): Int = command.exitStatus
+
+ override fun getInputStream(): InputStream = command.inputStream
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt b/app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt
new file mode 100644
index 00000000..f3dae627
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.util.proxy
+
+import dev.msfjarvis.aps.util.settings.GitSettings
+import java.io.IOException
+import java.net.Authenticator
+import java.net.InetSocketAddress
+import java.net.PasswordAuthentication
+import java.net.Proxy
+import java.net.ProxySelector
+import java.net.SocketAddress
+import java.net.URI
+
+/**
+ * Utility class for [Proxy] handling.
+ */
+object ProxyUtils {
+
+ private const val HTTP_PROXY_USER_PROPERTY = "http.proxyUser"
+ private const val HTTP_PROXY_PASSWORD_PROPERTY = "http.proxyPassword"
+
+ /**
+ * Set the default [Proxy] and [Authenticator] for the app based on user provided settings.
+ */
+ fun setDefaultProxy() {
+ ProxySelector.setDefault(object : ProxySelector() {
+ override fun select(uri: URI?): MutableList<Proxy> {
+ val host = GitSettings.proxyHost
+ val port = GitSettings.proxyPort
+ return if (host == null || port == -1) {
+ mutableListOf(Proxy.NO_PROXY)
+ } else {
+ mutableListOf(Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(host, port)))
+ }
+ }
+
+ override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) {
+ if (uri == null || sa == null || ioe == null) {
+ throw IllegalArgumentException("Arguments can't be null.")
+ }
+ }
+ })
+ val user = GitSettings.proxyUsername ?: ""
+ val password = GitSettings.proxyPassword ?: ""
+ if (user.isEmpty() || password.isEmpty()) {
+ System.clearProperty(HTTP_PROXY_USER_PROPERTY)
+ System.clearProperty(HTTP_PROXY_PASSWORD_PROPERTY)
+ } else {
+ System.setProperty(HTTP_PROXY_USER_PROPERTY, user)
+ System.setProperty(HTTP_PROXY_PASSWORD_PROPERTY, password)
+ }
+ Authenticator.setDefault(object : Authenticator() {
+ override fun getPasswordAuthentication(): PasswordAuthentication? {
+ return if (requestorType == RequestorType.PROXY) {
+ PasswordAuthentication(user, password.toCharArray())
+ } else {
+ null
+ }
+ }
+ })
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt
new file mode 100644
index 00000000..ae26c7e3
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt
@@ -0,0 +1,139 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.pwgen
+
+import android.content.Context
+import androidx.core.content.edit
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.extensions.clearFlag
+import dev.msfjarvis.aps.util.extensions.hasFlag
+
+enum class PasswordOption(val key: String) {
+ NoDigits("0"),
+ NoUppercaseLetters("A"),
+ NoAmbiguousCharacters("B"),
+ FullyRandom("s"),
+ AtLeastOneSymbol("y"),
+ NoLowercaseLetters("L")
+}
+
+object PasswordGenerator {
+
+ const val DEFAULT_LENGTH = 16
+
+ const val DIGITS = 0x0001
+ const val UPPERS = 0x0002
+ const val SYMBOLS = 0x0004
+ const val NO_AMBIGUOUS = 0x0008
+ const val LOWERS = 0x0020
+
+ const val DIGITS_STR = "0123456789"
+ const val UPPERS_STR = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ const val LOWERS_STR = "abcdefghijklmnopqrstuvwxyz"
+ const val SYMBOLS_STR = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
+ const val AMBIGUOUS_STR = "B8G6I1l0OQDS5Z2"
+
+ /**
+ * Enables the [PasswordOption]s in [options] and sets [targetLength] as the length for
+ * generated passwords.
+ */
+ fun setPrefs(ctx: Context, options: List<PasswordOption>, targetLength: Int): Boolean {
+ ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit {
+ for (possibleOption in PasswordOption.values())
+ putBoolean(possibleOption.key, possibleOption in options)
+ putInt("length", targetLength)
+ }
+ return true
+ }
+
+ fun isValidPassword(password: String, pwFlags: Int): Boolean {
+ if (pwFlags hasFlag DIGITS && password.none { it in DIGITS_STR })
+ return false
+ if (pwFlags hasFlag UPPERS && password.none { it in UPPERS_STR })
+ return false
+ if (pwFlags hasFlag LOWERS && password.none { it in LOWERS_STR })
+ return false
+ if (pwFlags hasFlag SYMBOLS && password.none { it in SYMBOLS_STR })
+ return false
+ if (pwFlags hasFlag NO_AMBIGUOUS && password.any { it in AMBIGUOUS_STR })
+ return false
+ return true
+ }
+
+ /**
+ * Generates a password using the preferences set by [setPrefs].
+ */
+ @Throws(PasswordGeneratorException::class)
+ fun generate(ctx: Context): String {
+ val prefs = ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
+ var numCharacterCategories = 0
+
+ var phonemes = true
+ var pwgenFlags = DIGITS or UPPERS or LOWERS
+
+ for (option in PasswordOption.values()) {
+ if (prefs.getBoolean(option.key, false)) {
+ when (option) {
+ PasswordOption.NoDigits -> pwgenFlags = pwgenFlags.clearFlag(DIGITS)
+ PasswordOption.NoUppercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(UPPERS)
+ PasswordOption.NoLowercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(LOWERS)
+ PasswordOption.NoAmbiguousCharacters -> pwgenFlags = pwgenFlags or NO_AMBIGUOUS
+ PasswordOption.FullyRandom -> phonemes = false
+ PasswordOption.AtLeastOneSymbol -> {
+ numCharacterCategories++
+ pwgenFlags = pwgenFlags or SYMBOLS
+ }
+ }
+ } else {
+ // The No* options are false, so the respective character category will be included.
+ when (option) {
+ PasswordOption.NoDigits,
+ PasswordOption.NoUppercaseLetters,
+ PasswordOption.NoLowercaseLetters -> {
+ numCharacterCategories++
+ }
+ PasswordOption.NoAmbiguousCharacters,
+ PasswordOption.FullyRandom,
+ // Since AtLeastOneSymbol is not negated, it is counted in the if branch.
+ PasswordOption.AtLeastOneSymbol -> {
+ }
+ }
+ }
+ }
+
+ val length = prefs.getInt(PreferenceKeys.LENGTH, DEFAULT_LENGTH)
+ if (pwgenFlags.clearFlag(NO_AMBIGUOUS) == 0) {
+ throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_no_chars_error))
+ }
+ if (length < numCharacterCategories) {
+ throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_length_too_short_error))
+ }
+ if (!(pwgenFlags hasFlag UPPERS) && !(pwgenFlags hasFlag LOWERS)) {
+ phonemes = false
+ pwgenFlags = pwgenFlags.clearFlag(NO_AMBIGUOUS)
+ }
+ // Experiments show that phonemes may require more than 1000 iterations to generate a valid
+ // password if the length is not at least 6.
+ if (length < 6) {
+ phonemes = false
+ }
+
+ var password: String?
+ var iterations = 0
+ do {
+ if (iterations++ > 1000)
+ throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_max_iterations_exceeded))
+ password = if (phonemes) {
+ RandomPhonemesGenerator.generate(length, pwgenFlags)
+ } else {
+ RandomPasswordGenerator.generate(length, pwgenFlags)
+ }
+ } while (password == null)
+ return password
+ }
+
+ class PasswordGeneratorException(string: String) : Exception(string)
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt
new file mode 100644
index 00000000..aae8d987
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.pwgen
+
+import java.security.SecureRandom
+
+private val secureRandom = SecureRandom()
+
+/**
+ * Returns a number between 0 (inclusive) and [exclusiveBound] (exclusive).
+ */
+fun secureRandomNumber(exclusiveBound: Int) = secureRandom.nextInt(exclusiveBound)
+
+/**
+ * Returns `true` and `false` with probablity 50% each.
+ */
+fun secureRandomBoolean() = secureRandom.nextBoolean()
+
+/**
+ * Returns `true` with probability [percentTrue]% and `false` with probability
+ * `(100 - [percentTrue])`%.
+ */
+fun secureRandomBiasedBoolean(percentTrue: Int): Boolean {
+ require(1 <= percentTrue) { "Probability for returning `true` must be at least 1%" }
+ require(percentTrue <= 99) { "Probability for returning `true` must be at most 99%" }
+ return secureRandomNumber(100) < percentTrue
+}
+
+fun <T> Array<T>.secureRandomElement() = this[secureRandomNumber(size)]
+fun <T> List<T>.secureRandomElement() = this[secureRandomNumber(size)]
+fun String.secureRandomCharacter() = this[secureRandomNumber(length)]
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt
new file mode 100644
index 00000000..f6341087
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.pwgen
+
+import dev.msfjarvis.aps.util.extensions.hasFlag
+
+object RandomPasswordGenerator {
+
+ /**
+ * Generates a random password of length [targetLength], taking the following flags in [pwFlags]
+ * into account, or fails to do so and returns null:
+ *
+ * - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not
+ * set, the password will not contain any digits.
+ * - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase
+ * letter; if not set, the password will not contain any uppercase letters.
+ * - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase
+ * letter; if not set, the password will not contain any lowercase letters.
+ * - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not
+ * set, the password will not contain any symbols.
+ * - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous
+ * characters.
+ */
+ fun generate(targetLength: Int, pwFlags: Int): String? {
+ val bank = listOfNotNull(
+ PasswordGenerator.DIGITS_STR.takeIf { pwFlags hasFlag PasswordGenerator.DIGITS },
+ PasswordGenerator.UPPERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.UPPERS },
+ PasswordGenerator.LOWERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.LOWERS },
+ PasswordGenerator.SYMBOLS_STR.takeIf { pwFlags hasFlag PasswordGenerator.SYMBOLS },
+ ).joinToString("")
+
+ var password = ""
+ while (password.length < targetLength) {
+ val candidate = bank.secureRandomCharacter()
+ if (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS &&
+ candidate in PasswordGenerator.AMBIGUOUS_STR) {
+ continue
+ }
+ password += candidate
+ }
+ return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt
new file mode 100644
index 00000000..408974d5
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt
@@ -0,0 +1,169 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.pwgen
+
+import dev.msfjarvis.aps.util.extensions.hasFlag
+import java.util.Locale
+
+object RandomPhonemesGenerator {
+
+ private const val CONSONANT = 0x0001
+ private const val VOWEL = 0x0002
+ private const val DIPHTHONG = 0x0004
+ private const val NOT_FIRST = 0x0008
+
+ private val elements = arrayOf(
+ Element("a", VOWEL),
+ Element("ae", VOWEL or DIPHTHONG),
+ Element("ah", VOWEL or DIPHTHONG),
+ Element("ai", VOWEL or DIPHTHONG),
+ Element("b", CONSONANT),
+ Element("c", CONSONANT),
+ Element("ch", CONSONANT or DIPHTHONG),
+ Element("d", CONSONANT),
+ Element("e", VOWEL),
+ Element("ee", VOWEL or DIPHTHONG),
+ Element("ei", VOWEL or DIPHTHONG),
+ Element("f", CONSONANT),
+ Element("g", CONSONANT),
+ Element("gh", CONSONANT or DIPHTHONG or NOT_FIRST),
+ Element("h", CONSONANT),
+ Element("i", VOWEL),
+ Element("ie", VOWEL or DIPHTHONG),
+ Element("j", CONSONANT),
+ Element("k", CONSONANT),
+ Element("l", CONSONANT),
+ Element("m", CONSONANT),
+ Element("n", CONSONANT),
+ Element("ng", CONSONANT or DIPHTHONG or NOT_FIRST),
+ Element("o", VOWEL),
+ Element("oh", VOWEL or DIPHTHONG),
+ Element("oo", VOWEL or DIPHTHONG),
+ Element("p", CONSONANT),
+ Element("ph", CONSONANT or DIPHTHONG),
+ Element("qu", CONSONANT or DIPHTHONG),
+ Element("r", CONSONANT),
+ Element("s", CONSONANT),
+ Element("sh", CONSONANT or DIPHTHONG),
+ Element("t", CONSONANT),
+ Element("th", CONSONANT or DIPHTHONG),
+ Element("u", VOWEL),
+ Element("v", CONSONANT),
+ Element("w", CONSONANT),
+ Element("x", CONSONANT),
+ Element("y", CONSONANT),
+ Element("z", CONSONANT)
+ )
+
+ private class Element(str: String, val flags: Int) {
+
+ val upperCase = str.toUpperCase(Locale.ROOT)
+ val lowerCase = str.toLowerCase(Locale.ROOT)
+ val length = str.length
+ val isAmbiguous = str.any { it in PasswordGenerator.AMBIGUOUS_STR }
+ }
+
+ /**
+ * Generates a random human-readable password of length [targetLength], taking the following
+ * flags in [pwFlags] into account, or fails to do so and returns null:
+ *
+ * - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not
+ * set, the password will not contain any digits.
+ * - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase
+ * letter; if not set, the password will not contain any uppercase letters.
+ * - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase
+ * letter; if not set and [PasswordGenerator.UPPERS] is set, the password will not contain any
+ * lowercase characters; if both are not set, an exception is thrown.
+ * - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not
+ * set, the password will not contain any symbols.
+ * - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous
+ * characters.
+ */
+ fun generate(targetLength: Int, pwFlags: Int): String? {
+ require(pwFlags hasFlag PasswordGenerator.UPPERS || pwFlags hasFlag PasswordGenerator.LOWERS)
+
+ var password = ""
+
+ var isStartOfPart = true
+ var nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT
+ var previousFlags = 0
+
+ while (password.length < targetLength) {
+ // First part: Add a single letter or pronounceable pair of letters in varying case.
+
+ val candidate = elements.secureRandomElement()
+
+ // Reroll if the candidate does not fulfill the current requirements.
+ if (!candidate.flags.hasFlag(nextBasicType) ||
+ (isStartOfPart && candidate.flags hasFlag NOT_FIRST) ||
+ // Don't let a diphthong that starts with a vowel follow a vowel.
+ (previousFlags hasFlag VOWEL && candidate.flags hasFlag VOWEL && candidate.flags hasFlag DIPHTHONG) ||
+ // Don't add multi-character candidates if we would go over the targetLength.
+ (password.length + candidate.length > targetLength) ||
+ (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate.isAmbiguous)) {
+ continue
+ }
+
+ // At this point the candidate could be appended to the password, but we still have
+ // to determine the case. If no upper case characters are required, we don't add
+ // any.
+ val useUpperIfBothCasesAllowed =
+ (isStartOfPart || candidate.flags hasFlag CONSONANT) && secureRandomBiasedBoolean(20)
+ password += if (pwFlags hasFlag PasswordGenerator.UPPERS &&
+ (!(pwFlags hasFlag PasswordGenerator.LOWERS) || useUpperIfBothCasesAllowed)) {
+ candidate.upperCase
+ } else {
+ candidate.lowerCase
+ }
+
+ // We ensured above that we will not go above the target length.
+ check(password.length <= targetLength)
+ if (password.length == targetLength)
+ break
+
+ // Second part: Add digits and symbols with a certain probability (if requested) if
+ // they would not directly follow the first character in a pronounceable part.
+
+ if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.DIGITS &&
+ secureRandomBiasedBoolean(30)) {
+ var randomDigit: Char
+ do {
+ randomDigit = secureRandomNumber(10).toString(10).first()
+ } while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS &&
+ randomDigit in PasswordGenerator.AMBIGUOUS_STR)
+
+ password += randomDigit
+ // Begin a new pronounceable part after every digit.
+ isStartOfPart = true
+ nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT
+ previousFlags = 0
+ continue
+ }
+
+ if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.SYMBOLS &&
+ secureRandomBiasedBoolean(20)) {
+ var randomSymbol: Char
+ do {
+ randomSymbol = PasswordGenerator.SYMBOLS_STR.secureRandomCharacter()
+ } while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS &&
+ randomSymbol in PasswordGenerator.AMBIGUOUS_STR)
+ password += randomSymbol
+ // Continue the password generation as if nothing was added.
+ }
+
+ // Third part: Determine the basic type of the next character depending on the letter
+ // we just added.
+ nextBasicType = when {
+ candidate.flags.hasFlag(CONSONANT) -> VOWEL
+ previousFlags.hasFlag(VOWEL) || candidate.flags.hasFlag(DIPHTHONG) ||
+ secureRandomBiasedBoolean(60) -> CONSONANT
+ else -> VOWEL
+ }
+ previousFlags = candidate.flags
+ isStartOfPart = false
+ }
+ return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/CapsType.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/CapsType.kt
new file mode 100644
index 00000000..83274171
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/CapsType.kt
@@ -0,0 +1,9 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.pwgenxkpwd
+
+enum class CapsType {
+ lowercase, UPPERCASE, TitleCase, Sentence, As_iS
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/PasswordBuilder.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/PasswordBuilder.kt
new file mode 100644
index 00000000..4bd1a6e6
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/PasswordBuilder.kt
@@ -0,0 +1,142 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.pwgenxkpwd
+
+import android.content.Context
+import com.github.michaelbull.result.Result
+import com.github.michaelbull.result.runCatching
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.util.pwgen.PasswordGenerator.PasswordGeneratorException
+import dev.msfjarvis.aps.util.pwgen.secureRandomCharacter
+import dev.msfjarvis.aps.util.pwgen.secureRandomElement
+import dev.msfjarvis.aps.util.pwgen.secureRandomNumber
+import java.util.Locale
+
+class PasswordBuilder(ctx: Context) {
+
+ private var numSymbols = 0
+ private var isAppendSymbolsSeparator = false
+ private var context = ctx
+ private var numWords = 3
+ private var maxWordLength = 9
+ private var minWordLength = 5
+ private var separator = "."
+ private var capsType = CapsType.Sentence
+ private var prependDigits = 0
+ private var numDigits = 0
+ private var isPrependWithSeparator = false
+ private var isAppendNumberSeparator = false
+
+ fun setNumberOfWords(amount: Int) = apply {
+ numWords = amount
+ }
+
+ fun setMinimumWordLength(min: Int) = apply {
+ minWordLength = min
+ }
+
+ fun setMaximumWordLength(max: Int) = apply {
+ maxWordLength = max
+ }
+
+ fun setSeparator(separator: String) = apply {
+ this.separator = separator
+ }
+
+ fun setCapitalization(capitalizationScheme: CapsType) = apply {
+ capsType = capitalizationScheme
+ }
+
+ @JvmOverloads
+ fun prependNumbers(numDigits: Int, addSeparator: Boolean = true) = apply {
+ prependDigits = numDigits
+ isPrependWithSeparator = addSeparator
+ }
+
+ @JvmOverloads
+ fun appendNumbers(numDigits: Int, addSeparator: Boolean = false) = apply {
+ this.numDigits = numDigits
+ isAppendNumberSeparator = addSeparator
+ }
+
+ @JvmOverloads
+ fun appendSymbols(numSymbols: Int, addSeparator: Boolean = false) = apply {
+ this.numSymbols = numSymbols
+ isAppendSymbolsSeparator = addSeparator
+ }
+
+ private fun generateRandomNumberSequence(totalNumbers: Int): String {
+ val numbers = StringBuilder(totalNumbers)
+ for (i in 0 until totalNumbers) {
+ numbers.append(secureRandomNumber(10))
+ }
+ return numbers.toString()
+ }
+
+ private fun generateRandomSymbolSequence(numSymbols: Int): String {
+ val numbers = StringBuilder(numSymbols)
+ for (i in 0 until numSymbols) {
+ numbers.append(SYMBOLS.secureRandomCharacter())
+ }
+ return numbers.toString()
+ }
+
+ @OptIn(ExperimentalStdlibApi::class)
+ fun create(): Result<String, Throwable> {
+ val wordBank = mutableListOf<String>()
+ val password = StringBuilder()
+
+ if (prependDigits != 0) {
+ password.append(generateRandomNumberSequence(prependDigits))
+ if (isPrependWithSeparator) {
+ password.append(separator)
+ }
+ }
+ return runCatching {
+ val dictionary = XkpwdDictionary(context)
+ val words = dictionary.words
+ for (wordLength in minWordLength..maxWordLength) {
+ wordBank.addAll(words[wordLength] ?: emptyList())
+ }
+
+ if (wordBank.size == 0) {
+ throw PasswordGeneratorException(context.getString(R.string.xkpwgen_builder_error, minWordLength, maxWordLength))
+ }
+
+ for (i in 0 until numWords) {
+ val candidate = wordBank.secureRandomElement()
+ val s = when (capsType) {
+ CapsType.UPPERCASE -> candidate.toUpperCase(Locale.getDefault())
+ CapsType.Sentence -> if (i == 0) candidate.capitalize(Locale.getDefault()) else candidate
+ CapsType.TitleCase -> candidate.capitalize(Locale.getDefault())
+ CapsType.lowercase -> candidate.toLowerCase(Locale.getDefault())
+ CapsType.As_iS -> candidate
+ }
+ password.append(s)
+ if (i + 1 < numWords) {
+ password.append(separator)
+ }
+ }
+ if (numDigits != 0) {
+ if (isAppendNumberSeparator) {
+ password.append(separator)
+ }
+ password.append(generateRandomNumberSequence(numDigits))
+ }
+ if (numSymbols != 0) {
+ if (isAppendSymbolsSeparator) {
+ password.append(separator)
+ }
+ password.append(generateRandomSymbolSequence(numSymbols))
+ }
+ password.toString()
+ }
+ }
+
+ companion object {
+
+ private const val SYMBOLS = "!@\$%^&*-_+=:|~?/.;#"
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/XkpwdDictionary.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/XkpwdDictionary.kt
new file mode 100644
index 00000000..95e65bcb
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/XkpwdDictionary.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.pwgenxkpwd
+
+import android.content.Context
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.extensions.getString
+import dev.msfjarvis.aps.util.extensions.sharedPrefs
+import java.io.File
+
+class XkpwdDictionary(context: Context) {
+
+ val words: Map<Int, List<String>>
+
+ init {
+ val prefs = context.sharedPrefs
+ val uri = prefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT) ?: ""
+ val customDictFile = File(context.filesDir, XKPWD_CUSTOM_DICT_FILE)
+
+ val lines = if (prefs.getBoolean(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT, false) &&
+ uri.isNotEmpty() && customDictFile.canRead()) {
+ customDictFile.readLines()
+ } else {
+ context.resources.openRawResource(R.raw.xkpwdict).bufferedReader().readLines()
+ }
+
+ words = lines.asSequence()
+ .map { it.trim() }
+ .filter { it.isNotEmpty() && !it.contains(' ') }
+ .groupBy { it.length }
+ }
+
+ companion object {
+
+ const val XKPWD_CUSTOM_DICT_FILE = "custom_dict.txt"
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt b/app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt
new file mode 100644
index 00000000..46363420
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt
@@ -0,0 +1,183 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.services
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.ClipData
+import android.content.Intent
+import android.os.Build
+import android.os.IBinder
+import androidx.annotation.RequiresApi
+import androidx.core.app.NotificationCompat
+import androidx.core.content.getSystemService
+import com.github.ajalt.timberkt.d
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.extensions.clipboard
+import dev.msfjarvis.aps.util.extensions.sharedPrefs
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class ClipboardService : Service() {
+
+ private val scope = CoroutineScope(Job() + Dispatchers.Main)
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (intent != null) {
+ when (intent.action) {
+ ACTION_CLEAR -> {
+ clearClipboard()
+ stopForeground(true)
+ stopSelf()
+ return super.onStartCommand(intent, flags, startId)
+ }
+
+ ACTION_START -> {
+ val time = intent.getIntExtra(EXTRA_NOTIFICATION_TIME, 45)
+
+ if (time == 0) {
+ stopSelf()
+ }
+
+ createNotification(time)
+ scope.launch {
+ withContext(Dispatchers.IO) {
+ startTimer(time)
+ }
+ withContext(Dispatchers.Main) {
+ clearClipboard()
+ stopForeground(true)
+ stopSelf()
+ }
+ }
+ return START_NOT_STICKY
+ }
+ }
+ }
+
+ return super.onStartCommand(intent, flags, startId)
+ }
+
+ override fun onBind(intent: Intent?): IBinder? {
+ return null
+ }
+
+ override fun onDestroy() {
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ private fun clearClipboard() {
+ val deepClear = sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, false)
+ val clipboard = clipboard
+
+ if (clipboard != null) {
+ scope.launch {
+ d { "Clearing the clipboard" }
+ val clip = ClipData.newPlainText("pgp_handler_result_pm", "")
+ clipboard.setPrimaryClip(clip)
+ if (deepClear) {
+ withContext(Dispatchers.IO) {
+ repeat(20) {
+ val count = (it * 500).toString()
+ clipboard.setPrimaryClip(ClipData.newPlainText(count, count))
+ }
+ }
+ }
+ }
+ } else {
+ d { "Cannot get clipboard manager service" }
+ }
+ }
+
+ private suspend fun startTimer(showTime: Int) {
+ var current = 0
+ while (scope.isActive && current < showTime) {
+ // Block for 1s or until cancel is signalled
+ current++
+ delay(1000)
+ }
+ }
+
+ private fun createNotification(clearTime: Int) {
+ val clearTimeMs = clearTime * 1000L
+ val clearIntent = Intent(this, ClipboardService::class.java).apply {
+ action = ACTION_CLEAR
+ }
+ val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ PendingIntent.getForegroundService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT)
+ } else {
+ PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT)
+ }
+ val notification = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
+ createNotificationApi23(pendingIntent)
+ } else {
+ createNotificationApi24(pendingIntent, clearTimeMs)
+ }
+
+ createNotificationChannel()
+ startForeground(1, notification)
+ }
+
+ private fun createNotificationApi23(pendingIntent: PendingIntent): Notification {
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.app_name))
+ .setContentText(getString(R.string.tap_clear_clipboard))
+ .setSmallIcon(R.drawable.ic_action_secure_24dp)
+ .setContentIntent(pendingIntent)
+ .setUsesChronometer(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .build()
+ }
+
+ @RequiresApi(Build.VERSION_CODES.N)
+ private fun createNotificationApi24(pendingIntent: PendingIntent, clearTimeMs: Long): Notification {
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.app_name))
+ .setContentText(getString(R.string.tap_clear_clipboard))
+ .setSmallIcon(R.drawable.ic_action_secure_24dp)
+ .setContentIntent(pendingIntent)
+ .setUsesChronometer(true)
+ .setChronometerCountDown(true)
+ .setShowWhen(true)
+ .setWhen(System.currentTimeMillis() + clearTimeMs)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .build()
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val serviceChannel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.app_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ val manager = getSystemService<NotificationManager>()
+ if (manager != null) {
+ manager.createNotificationChannel(serviceChannel)
+ } else {
+ d { "Failed to create notification channel" }
+ }
+ }
+ }
+
+ companion object {
+
+ const val ACTION_START = "ACTION_START_CLIPBOARD_TIMER"
+ const val EXTRA_NOTIFICATION_TIME = "EXTRA_NOTIFICATION_TIME"
+ private const val ACTION_CLEAR = "ACTION_CLEAR_CLIPBOARD"
+ private const val CHANNEL_ID = "NotificationService"
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt b/app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt
new file mode 100644
index 00000000..3b431525
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt
@@ -0,0 +1,145 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.services
+
+import android.content.Context
+import android.os.Build
+import android.os.CancellationSignal
+import android.service.autofill.AutofillService
+import android.service.autofill.FillCallback
+import android.service.autofill.FillRequest
+import android.service.autofill.FillResponse
+import android.service.autofill.SaveCallback
+import android.service.autofill.SaveRequest
+import androidx.annotation.RequiresApi
+import com.github.ajalt.timberkt.d
+import com.github.ajalt.timberkt.e
+import com.github.androidpasswordstore.autofillparser.AutofillScenario
+import com.github.androidpasswordstore.autofillparser.Credentials
+import com.github.androidpasswordstore.autofillparser.FillableForm
+import com.github.androidpasswordstore.autofillparser.FixedSaveCallback
+import com.github.androidpasswordstore.autofillparser.FormOrigin
+import com.github.androidpasswordstore.autofillparser.cachePublicSuffixList
+import com.github.androidpasswordstore.autofillparser.passwordValue
+import com.github.androidpasswordstore.autofillparser.recoverNodes
+import com.github.androidpasswordstore.autofillparser.usernameValue
+import dev.msfjarvis.aps.BuildConfig
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity
+import dev.msfjarvis.aps.util.autofill.Api30AutofillResponseBuilder
+import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.extensions.getString
+import dev.msfjarvis.aps.util.extensions.hasFlag
+import dev.msfjarvis.aps.util.extensions.sharedPrefs
+
+@RequiresApi(Build.VERSION_CODES.O)
+class OreoAutofillService : AutofillService() {
+
+ companion object {
+
+ // TODO: Provide a user-configurable denylist
+ private val DENYLISTED_PACKAGES = listOf(
+ BuildConfig.APPLICATION_ID,
+ "android",
+ "com.android.settings",
+ "com.android.settings.intelligence",
+ "com.android.systemui",
+ "com.oneplus.applocker",
+ "org.sufficientlysecure.keychain",
+ )
+
+ private const val DISABLE_AUTOFILL_DURATION_MS = 1000 * 60 * 60 * 24L
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ cachePublicSuffixList(applicationContext)
+ }
+
+ override fun onFillRequest(
+ request: FillRequest,
+ cancellationSignal: CancellationSignal,
+ callback: FillCallback
+ ) {
+ val structure = request.fillContexts.lastOrNull()?.structure ?: run {
+ callback.onSuccess(null)
+ return
+ }
+ if (structure.activityComponent.packageName in DENYLISTED_PACKAGES) {
+ if (Build.VERSION.SDK_INT >= 28) {
+ callback.onSuccess(FillResponse.Builder().run {
+ disableAutofill(DISABLE_AUTOFILL_DURATION_MS)
+ build()
+ })
+ } else {
+ callback.onSuccess(null)
+ }
+ return
+ }
+ val formToFill = FillableForm.parseAssistStructure(
+ this, structure,
+ isManualRequest = request.flags hasFlag FillRequest.FLAG_MANUAL_REQUEST,
+ getCustomSuffixes(),
+ ) ?: run {
+ d { "Form cannot be filled" }
+ callback.onSuccess(null)
+ return
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ Api30AutofillResponseBuilder(formToFill).fillCredentials(this, request.inlineSuggestionsRequest, callback)
+ } else {
+ AutofillResponseBuilder(formToFill).fillCredentials(this, callback)
+ }
+ }
+
+ override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {
+ // SaveCallback's behavior and feature set differs based on both target and device SDK, so
+ // we replace it with a wrapper that works the same in all situations.
+ @Suppress("NAME_SHADOWING") val callback = FixedSaveCallback(this, callback)
+ val structure = request.fillContexts.lastOrNull()?.structure ?: run {
+ callback.onFailure(getString(R.string.oreo_autofill_save_app_not_supported))
+ return
+ }
+ val clientState = request.clientState ?: run {
+ e { "Received save request without client state" }
+ callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
+ return
+ }
+ val scenario = AutofillScenario.fromClientState(clientState)?.recoverNodes(structure)
+ ?: run {
+ e { "Failed to recover client state or nodes from client state" }
+ callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
+ return
+ }
+ val formOrigin = FormOrigin.fromBundle(clientState) ?: run {
+ e { "Failed to recover form origin from client state" }
+ callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
+ return
+ }
+
+ val username = scenario.usernameValue
+ val password = scenario.passwordValue ?: run {
+ callback.onFailure(getString(R.string.oreo_autofill_save_passwords_dont_match))
+ return
+ }
+ callback.onSuccess(
+ AutofillSaveActivity.makeSaveIntentSender(
+ this,
+ credentials = Credentials(username, password, null),
+ formOrigin = formOrigin
+ )
+ )
+ }
+}
+
+fun Context.getDefaultUsername() = sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME)
+
+fun Context.getCustomSuffixes(): Sequence<String> {
+ return sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES)
+ ?.splitToSequence('\n')
+ ?.filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' }
+ ?: emptySequence()
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt b/app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt
new file mode 100644
index 00000000..9164aa46
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt
@@ -0,0 +1,158 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.util.services
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import androidx.core.content.getSystemService
+import androidx.documentfile.provider.DocumentFile
+import com.github.ajalt.timberkt.d
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.data.repo.PasswordRepository
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+import java.util.Calendar
+import java.util.TimeZone
+
+class PasswordExportService : Service() {
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (intent != null) {
+ when (intent.action) {
+ ACTION_EXPORT_PASSWORD -> {
+ val uri = intent.getParcelableExtra<Uri>("uri")
+ if (uri != null) {
+ val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri)
+
+ if (targetDirectory != null) {
+ createNotification()
+ exportPasswords(targetDirectory)
+ stopSelf()
+ return START_NOT_STICKY
+ }
+ }
+ }
+ }
+ }
+ return super.onStartCommand(intent, flags, startId)
+ }
+
+ override fun onBind(intent: Intent?): IBinder? {
+ return null
+ }
+
+ /**
+ * Exports passwords to the given directory.
+ *
+ * Recursively copies the existing password store to an external directory.
+ *
+ * @param targetDirectory directory to copy password directory to.
+ */
+ private fun exportPasswords(targetDirectory: DocumentFile) {
+
+ val repositoryDirectory = requireNotNull(PasswordRepository.getRepositoryDirectory())
+ val sourcePassDir = DocumentFile.fromFile(repositoryDirectory)
+
+ d { "Copying ${repositoryDirectory.path} to $targetDirectory" }
+
+ val dateString = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ LocalDateTime
+ .now()
+ .format(DateTimeFormatter.ISO_DATE_TIME)
+ } else {
+ String.format("%tFT%<tRZ", Calendar.getInstance(TimeZone.getTimeZone("Z")))
+ }
+
+ val passDir = targetDirectory.createDirectory("password_store_$dateString")
+
+ if (passDir != null) {
+ copyDirToDir(sourcePassDir, passDir)
+ }
+ }
+
+ /**
+ * Copies a password file to a given directory.
+ *
+ * Note: this does not preserve last modified time.
+ *
+ * @param passwordFile password file to copy.
+ * @param targetDirectory target directory to copy password.
+ */
+ private fun copyFileToDir(passwordFile: DocumentFile, targetDirectory: DocumentFile) {
+ val sourceInputStream = contentResolver.openInputStream(passwordFile.uri)
+ val name = passwordFile.name
+ val targetPasswordFile = targetDirectory.createFile("application/octet-stream", name!!)
+ if (targetPasswordFile?.exists() == true) {
+ val destOutputStream = contentResolver.openOutputStream(targetPasswordFile.uri)
+
+ if (destOutputStream != null && sourceInputStream != null) {
+ sourceInputStream.copyTo(destOutputStream, 1024)
+
+ sourceInputStream.close()
+ destOutputStream.close()
+ }
+ }
+ }
+
+ /**
+ * Recursively copies a directory to a destination.
+ *
+ * @param sourceDirectory directory to copy from.
+ * @param targetDirectory directory to copy to.
+ */
+ private fun copyDirToDir(sourceDirectory: DocumentFile, targetDirectory: DocumentFile) {
+ sourceDirectory.listFiles().forEach { file ->
+ if (file.isDirectory) {
+ // Create new directory and recurse
+ val newDir = targetDirectory.createDirectory(file.name!!)
+ copyDirToDir(file, newDir!!)
+ } else {
+ copyFileToDir(file, targetDirectory)
+ }
+ }
+ }
+
+ private fun createNotification() {
+ createNotificationChannel()
+
+ val notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.app_name))
+ .setContentText(getString(R.string.exporting_passwords))
+ .setSmallIcon(R.drawable.ic_round_import_export)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .build()
+
+ startForeground(2, notification)
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val serviceChannel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.app_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ val manager = getSystemService<NotificationManager>()
+ if (manager != null) {
+ manager.createNotificationChannel(serviceChannel)
+ } else {
+ d { "Failed to create notification channel" }
+ }
+ }
+ }
+
+ companion object {
+
+ const val ACTION_EXPORT_PASSWORD = "ACTION_EXPORT_PASSWORD"
+ private const val CHANNEL_ID = "NotificationService"
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt b/app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt
new file mode 100644
index 00000000..864cbf81
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt
@@ -0,0 +1,181 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.settings
+
+import androidx.core.content.edit
+import com.github.michaelbull.result.getOrElse
+import com.github.michaelbull.result.runCatching
+import dev.msfjarvis.aps.Application
+import dev.msfjarvis.aps.data.repo.PasswordRepository
+import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs
+import dev.msfjarvis.aps.util.extensions.getEncryptedProxyPrefs
+import dev.msfjarvis.aps.util.extensions.getString
+import dev.msfjarvis.aps.util.extensions.sharedPrefs
+import java.io.File
+import org.eclipse.jgit.transport.URIish
+
+enum class Protocol(val pref: String) {
+ Ssh("ssh://"),
+ Https("https://"),
+ ;
+
+ companion object {
+
+ private val map = values().associateBy(Protocol::pref)
+ fun fromString(type: String?): Protocol {
+ return map[type ?: return Ssh]
+ ?: throw IllegalArgumentException("$type is not a valid Protocol")
+ }
+ }
+}
+
+enum class AuthMode(val pref: String) {
+ SshKey("ssh-key"),
+ Password("username/password"),
+ OpenKeychain("OpenKeychain"),
+ None("None"),
+ ;
+
+ companion object {
+
+ private val map = values().associateBy(AuthMode::pref)
+ fun fromString(type: String?): AuthMode {
+ return map[type ?: return SshKey]
+ ?: throw IllegalArgumentException("$type is not a valid AuthMode")
+ }
+ }
+}
+
+object GitSettings {
+
+ private const val DEFAULT_BRANCH = "master"
+
+ private val settings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.sharedPrefs }
+ private val encryptedSettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedGitPrefs() }
+ private val proxySettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedProxyPrefs() }
+
+ var authMode
+ get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH))
+ private set(value) {
+ settings.edit {
+ putString(PreferenceKeys.GIT_REMOTE_AUTH, value.pref)
+ }
+ }
+ var url
+ get() = settings.getString(PreferenceKeys.GIT_REMOTE_URL)
+ private set(value) {
+ require(value != null)
+ if (value == url)
+ return
+ settings.edit {
+ putString(PreferenceKeys.GIT_REMOTE_URL, value)
+ }
+ if (PasswordRepository.isInitialized)
+ PasswordRepository.addRemote("origin", value, true)
+ // When the server changes, remote password, multiplexing support and host key file
+ // should be deleted/reset.
+ useMultiplexing = true
+ encryptedSettings.edit { remove(PreferenceKeys.HTTPS_PASSWORD) }
+ File("${Application.instance.filesDir}/.host_key").delete()
+ }
+ var authorName
+ get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME) ?: ""
+ set(value) {
+ settings.edit {
+ putString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME, value)
+ }
+ }
+ var authorEmail
+ get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL) ?: ""
+ set(value) {
+ settings.edit {
+ putString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, value)
+ }
+ }
+ var branch
+ get() = settings.getString(PreferenceKeys.GIT_BRANCH_NAME) ?: DEFAULT_BRANCH
+ private set(value) {
+ settings.edit {
+ putString(PreferenceKeys.GIT_BRANCH_NAME, value)
+ }
+ }
+ var useMultiplexing
+ get() = settings.getBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, true)
+ set(value) {
+ settings.edit {
+ putBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, value)
+ }
+ }
+
+ var proxyHost
+ get() = proxySettings.getString(PreferenceKeys.PROXY_HOST)
+ set(value) {
+ proxySettings.edit {
+ putString(PreferenceKeys.PROXY_HOST, value)
+ }
+ }
+
+ var proxyPort
+ get() = proxySettings.getInt(PreferenceKeys.PROXY_PORT, -1)
+ set(value) {
+ proxySettings.edit {
+ putInt(PreferenceKeys.PROXY_PORT, value)
+ }
+ }
+
+ var proxyUsername
+ get() = settings.getString(PreferenceKeys.PROXY_USERNAME)
+ set(value) {
+ proxySettings.edit {
+ putString(PreferenceKeys.PROXY_USERNAME, value)
+ }
+ }
+
+ var proxyPassword
+ get() = proxySettings.getString(PreferenceKeys.PROXY_PASSWORD)
+ set(value) {
+ proxySettings.edit {
+ putString(PreferenceKeys.PROXY_PASSWORD, value)
+ }
+ }
+
+ sealed class UpdateConnectionSettingsResult {
+ class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult()
+ class AuthModeMismatch(val newProtocol: Protocol, val validModes: List<AuthMode>) : UpdateConnectionSettingsResult()
+ object Valid : UpdateConnectionSettingsResult()
+ object FailedToParseUrl : UpdateConnectionSettingsResult()
+ }
+
+ fun updateConnectionSettingsIfValid(newAuthMode: AuthMode, newUrl: String, newBranch: String): UpdateConnectionSettingsResult {
+ val parsedUrl = runCatching {
+ URIish(newUrl)
+ }.getOrElse {
+ return UpdateConnectionSettingsResult.FailedToParseUrl
+ }
+ val newProtocol = when (parsedUrl.scheme) {
+ in listOf("http", "https") -> Protocol.Https
+ in listOf("ssh", null) -> Protocol.Ssh
+ else -> return UpdateConnectionSettingsResult.FailedToParseUrl
+ }
+ if (newAuthMode != AuthMode.None && parsedUrl.user.isNullOrBlank())
+ return UpdateConnectionSettingsResult.MissingUsername(newProtocol)
+
+ val validHttpsAuth = listOf(AuthMode.None, AuthMode.Password)
+ val validSshAuth = listOf(AuthMode.OpenKeychain, AuthMode.Password, AuthMode.SshKey)
+ when {
+ newProtocol == Protocol.Https && newAuthMode !in validHttpsAuth -> {
+ return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validHttpsAuth)
+ }
+ newProtocol == Protocol.Ssh && newAuthMode !in validSshAuth -> {
+ return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validSshAuth)
+ }
+ }
+
+ url = newUrl
+ authMode = newAuthMode
+ branch = newBranch
+ return UpdateConnectionSettingsResult.Valid
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt b/app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt
new file mode 100644
index 00000000..fc506995
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt
@@ -0,0 +1,114 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+@file:Suppress("DEPRECATION")
+
+package dev.msfjarvis.aps.util.settings
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import com.github.ajalt.timberkt.e
+import com.github.ajalt.timberkt.i
+import com.github.michaelbull.result.get
+import com.github.michaelbull.result.runCatching
+import dev.msfjarvis.aps.util.git.sshj.SshKey
+import dev.msfjarvis.aps.util.extensions.getString
+import dev.msfjarvis.aps.util.extensions.sharedPrefs
+import java.io.File
+import java.net.URI
+
+fun runMigrations(context: Context) {
+ val sharedPrefs = context.sharedPrefs
+ migrateToGitUrlBasedConfig(sharedPrefs)
+ migrateToHideAll(sharedPrefs)
+ migrateToSshKey(context, sharedPrefs)
+}
+
+private fun migrateToGitUrlBasedConfig(sharedPrefs: SharedPreferences) {
+ val serverHostname = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_SERVER)
+ ?: return
+ i { "Migrating to URL-based Git config" }
+ val serverPort = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PORT) ?: ""
+ val serverUser = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_USERNAME) ?: ""
+ val serverPath = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_LOCATION) ?: ""
+ val protocol = Protocol.fromString(sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PROTOCOL))
+
+ // Whether we need the leading ssh:// depends on the use of a custom port.
+ val hostnamePart = serverHostname.removePrefix("ssh://")
+ val url = when (protocol) {
+ Protocol.Ssh -> {
+ val userPart = if (serverUser.isEmpty()) "" else "${serverUser.trimEnd('@')}@"
+ val portPart =
+ if (serverPort == "22" || serverPort.isEmpty()) "" else ":$serverPort"
+ if (portPart.isEmpty()) {
+ "$userPart$hostnamePart:$serverPath"
+ } else {
+ // Only absolute paths are supported with custom ports.
+ if (!serverPath.startsWith('/'))
+ null
+ else
+ // We have to specify the ssh scheme as this is the only way to pass a custom
+ // port.
+ "ssh://$userPart$hostnamePart$portPart$serverPath"
+ }
+ }
+ Protocol.Https -> {
+ val portPart =
+ if (serverPort == "443" || serverPort.isEmpty()) "" else ":$serverPort"
+ val pathPart = serverPath.trimStart('/', ':')
+ val urlWithFreeEntryScheme = "$hostnamePart$portPart/$pathPart"
+ val url = when {
+ urlWithFreeEntryScheme.startsWith("https://") -> urlWithFreeEntryScheme
+ urlWithFreeEntryScheme.startsWith("http://") -> urlWithFreeEntryScheme.replaceFirst("http", "https")
+ else -> "https://$urlWithFreeEntryScheme"
+ }
+ runCatching {
+ if (URI(url).rawAuthority != null)
+ url
+ else
+ null
+ }.get()
+ }
+ }
+
+ sharedPrefs.edit {
+ remove(PreferenceKeys.GIT_REMOTE_LOCATION)
+ remove(PreferenceKeys.GIT_REMOTE_PORT)
+ remove(PreferenceKeys.GIT_REMOTE_SERVER)
+ remove(PreferenceKeys.GIT_REMOTE_USERNAME)
+ remove(PreferenceKeys.GIT_REMOTE_PROTOCOL)
+ }
+ if (url == null || GitSettings.updateConnectionSettingsIfValid(
+ newAuthMode = GitSettings.authMode,
+ newUrl = url,
+ newBranch = GitSettings.branch) != GitSettings.UpdateConnectionSettingsResult.Valid) {
+ e { "Failed to migrate to URL-based Git config, generated URL is invalid" }
+ }
+}
+
+private fun migrateToHideAll(sharedPrefs: SharedPreferences) {
+ sharedPrefs.all[PreferenceKeys.SHOW_HIDDEN_FOLDERS] ?: return
+ val isHidden = sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false)
+ sharedPrefs.edit {
+ remove(PreferenceKeys.SHOW_HIDDEN_FOLDERS)
+ putBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, isHidden)
+ }
+}
+
+private fun migrateToSshKey(context: Context, sharedPrefs: SharedPreferences) {
+ val privateKeyFile = File(context.filesDir, ".ssh_key")
+ if (sharedPrefs.contains(PreferenceKeys.USE_GENERATED_KEY) &&
+ !SshKey.exists &&
+ privateKeyFile.exists()) {
+ // Currently uses a private key imported or generated with an old version of Password Store.
+ // Generated keys come with a public key which the user should still be able to view after
+ // the migration (not possible for regular imported keys), hence the special case.
+ val isGeneratedKey = sharedPrefs.getBoolean(PreferenceKeys.USE_GENERATED_KEY, false)
+ SshKey.useLegacyKey(isGeneratedKey)
+ sharedPrefs.edit {
+ remove(PreferenceKeys.USE_GENERATED_KEY)
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt b/app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt
new file mode 100644
index 00000000..f5a639f0
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.util.settings
+
+import android.content.Context
+import android.content.SharedPreferences
+import dev.msfjarvis.aps.Application
+import dev.msfjarvis.aps.data.password.PasswordItem
+import dev.msfjarvis.aps.util.extensions.base64
+import dev.msfjarvis.aps.util.extensions.getString
+
+enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>) {
+
+ FOLDER_FIRST(Comparator { p1: PasswordItem, p2: PasswordItem ->
+ (p1.type + p1.name)
+ .compareTo(p2.type + p2.name, ignoreCase = true)
+ }),
+
+ INDEPENDENT(Comparator { p1: PasswordItem, p2: PasswordItem ->
+ p1.name.compareTo(p2.name, ignoreCase = true)
+ }),
+
+ RECENTLY_USED(Comparator { p1: PasswordItem, p2: PasswordItem ->
+ val recentHistory = Application.instance.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
+ val timeP1 = recentHistory.getString(p1.file.absolutePath.base64())
+ val timeP2 = recentHistory.getString(p2.file.absolutePath.base64())
+ when {
+ timeP1 != null && timeP2 != null -> timeP2.compareTo(timeP1)
+ timeP1 != null && timeP2 == null -> return@Comparator -1
+ timeP1 == null && timeP2 != null -> return@Comparator 1
+ else -> p1.name.compareTo(p2.name, ignoreCase = true)
+ }
+ }),
+
+ FILE_FIRST(Comparator { p1: PasswordItem, p2: PasswordItem ->
+ (p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true)
+ });
+
+ companion object {
+
+ @JvmStatic
+ fun getSortOrder(settings: SharedPreferences): PasswordSortOrder {
+ return valueOf(settings.getString(PreferenceKeys.SORT_ORDER) ?: FOLDER_FIRST.name)
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt b/app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt
new file mode 100644
index 00000000..198be889
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.util.settings
+
+object PreferenceKeys {
+
+ const val APP_THEME = "app_theme"
+ const val APP_VERSION = "app_version"
+ const val AUTOFILL_ENABLE = "autofill_enable"
+ const val BIOMETRIC_AUTH = "biometric_auth"
+ const val CLEAR_CLIPBOARD_20X = "clear_clipboard_20x"
+ const val CLEAR_SAVED_PASS = "clear_saved_pass"
+ const val COPY_ON_DECRYPT = "copy_on_decrypt"
+ const val ENABLE_DEBUG_LOGGING = "enable_debug_logging"
+ const val EXPORT_PASSWORDS = "export_passwords"
+ const val FILTER_RECURSIVELY = "filter_recursively"
+ const val GENERAL_SHOW_TIME = "general_show_time"
+ const val GIT_CONFIG = "git_config"
+ const val GIT_CONFIG_AUTHOR_EMAIL = "git_config_user_email"
+ const val GIT_CONFIG_AUTHOR_NAME = "git_config_user_name"
+ const val GIT_EXTERNAL = "git_external"
+ const val GIT_EXTERNAL_REPO = "git_external_repo"
+ const val GIT_REMOTE_AUTH = "git_remote_auth"
+ const val GIT_REMOTE_KEY_TYPE = "git_remote_key_type"
+
+ @Deprecated("Use GIT_REMOTE_URL instead")
+ const val GIT_REMOTE_LOCATION = "git_remote_location"
+ const val GIT_REMOTE_USE_MULTIPLEXING = "git_remote_use_multiplexing"
+
+ @Deprecated("Use GIT_REMOTE_URL instead")
+ const val GIT_REMOTE_PORT = "git_remote_port"
+
+ @Deprecated("Use GIT_REMOTE_URL instead")
+ const val GIT_REMOTE_PROTOCOL = "git_remote_protocol"
+ const val GIT_DELETE_REPO = "git_delete_repo"
+
+ @Deprecated("Use GIT_REMOTE_URL instead")
+ const val GIT_REMOTE_SERVER = "git_remote_server"
+ const val GIT_REMOTE_URL = "git_remote_url"
+
+ @Deprecated("Use GIT_REMOTE_URL instead")
+ const val GIT_REMOTE_USERNAME = "git_remote_username"
+ const val GIT_SERVER_INFO = "git_server_info"
+ const val GIT_BRANCH_NAME = "git_branch"
+ const val HTTPS_PASSWORD = "https_password"
+ const val LENGTH = "length"
+ const val OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES = "oreo_autofill_custom_public_suffixes"
+ const val OREO_AUTOFILL_DEFAULT_USERNAME = "oreo_autofill_default_username"
+ const val OREO_AUTOFILL_DIRECTORY_STRUCTURE = "oreo_autofill_directory_structure"
+ const val PREF_KEY_CUSTOM_DICT = "pref_key_custom_dict"
+ const val PREF_KEY_IS_CUSTOM_DICT = "pref_key_is_custom_dict"
+ const val PREF_KEY_PWGEN_TYPE = "pref_key_pwgen_type"
+ const val PREF_SELECT_EXTERNAL = "pref_select_external"
+ const val REPOSITORY_INITIALIZED = "repository_initialized"
+ const val REPO_CHANGED = "repo_changed"
+ const val SEARCH_ON_START = "search_on_start"
+ const val SHOW_EXTRA_CONTENT = "show_extra_content"
+
+ @Deprecated(
+ message = "Use SHOW_HIDDEN_CONTENTS instead",
+ replaceWith = ReplaceWith("PreferenceKeys.SHOW_HIDDEN_CONTENTS")
+ )
+ const val SHOW_HIDDEN_FOLDERS = "show_hidden_folders"
+ const val SHOW_HIDDEN_CONTENTS = "show_hidden_contents"
+ const val SORT_ORDER = "sort_order"
+ const val SHOW_PASSWORD = "show_password"
+ const val SSH_KEY = "ssh_key"
+ const val SSH_KEYGEN = "ssh_keygen"
+ const val SSH_KEY_LOCAL_PASSPHRASE = "ssh_key_local_passphrase"
+ const val SSH_OPENKEYSTORE_CLEAR_KEY_ID = "ssh_openkeystore_clear_keyid"
+ const val SSH_OPENKEYSTORE_KEYID = "ssh_openkeystore_keyid"
+ const val SSH_SEE_KEY = "ssh_see_key"
+
+ @Deprecated("To be used only in Migrations.kt")
+ const val USE_GENERATED_KEY = "use_generated_key"
+
+ const val PROXY_SETTINGS = "proxy_settings"
+ const val PROXY_HOST = "proxy_host"
+ const val PROXY_PORT = "proxy_port"
+ const val PROXY_USERNAME = "proxy_username"
+ const val PROXY_PASSWORD = "proxy_password"
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/totp/Otp.kt b/app/src/main/java/dev/msfjarvis/aps/util/totp/Otp.kt
new file mode 100644
index 00000000..551b2e5d
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/totp/Otp.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.util.totp
+
+import com.github.michaelbull.result.Err
+import com.github.michaelbull.result.runCatching
+import java.nio.ByteBuffer
+import java.util.Locale
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
+import kotlin.experimental.and
+import org.apache.commons.codec.binary.Base32
+
+object Otp {
+
+ private val BASE_32 = Base32()
+ private val STEAM_ALPHABET = "23456789BCDFGHJKMNPQRTVWXY".toCharArray()
+
+ init {
+ check(STEAM_ALPHABET.size == 26)
+ }
+
+ fun calculateCode(secret: String, counter: Long, algorithm: String, digits: String) = runCatching {
+ val algo = "Hmac${algorithm.toUpperCase(Locale.ROOT)}"
+ val decodedSecret = BASE_32.decode(secret)
+ val secretKey = SecretKeySpec(decodedSecret, algo)
+ val digest = Mac.getInstance(algo).run {
+ init(secretKey)
+ doFinal(ByteBuffer.allocate(8).putLong(counter).array())
+ }
+ // Least significant 4 bits are used as an offset into the digest.
+ val offset = (digest.last() and 0xf).toInt()
+ // Extract 32 bits at the offset and clear the most significant bit.
+ val code = digest.copyOfRange(offset, offset + 4)
+ code[0] = (0x7f and code[0].toInt()).toByte()
+ val codeInt = ByteBuffer.wrap(code).int
+ check(codeInt > 0)
+ if (digits == "s") {
+ // Steam
+ var remainingCodeInt = codeInt
+ buildString {
+ repeat(5) {
+ append(STEAM_ALPHABET[remainingCodeInt % 26])
+ remainingCodeInt /= 26
+ }
+ }
+ } else {
+ // Base 10, 6 to 10 digits
+ val numDigits = digits.toIntOrNull()
+ when {
+ numDigits == null -> {
+ return Err(IllegalArgumentException("Digits specifier has to be either 's' or numeric"))
+ }
+ numDigits < 6 -> {
+ return Err(IllegalArgumentException("TOTP codes have to be at least 6 digits long"))
+ }
+ numDigits > 10 -> {
+ return Err(IllegalArgumentException("TOTP codes can be at most 10 digits long"))
+ }
+ else -> {
+ // 2^31 = 2_147_483_648, so we can extract at most 10 digits with the first one
+ // always being 0, 1, or 2. Pad with leading zeroes.
+ val codeStringBase10 = codeInt.toString(10).padStart(10, '0')
+ check(codeStringBase10.length == 10)
+ codeStringBase10.takeLast(numDigits)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/totp/TotpFinder.kt b/app/src/main/java/dev/msfjarvis/aps/util/totp/TotpFinder.kt
new file mode 100644
index 00000000..fb43980c
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/totp/TotpFinder.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.util.totp
+
+/**
+ * Defines a class that can extract relevant parts of a TOTP URL for use by the app.
+ */
+interface TotpFinder {
+
+ /**
+ * Get the TOTP secret from the given extra content.
+ */
+ fun findSecret(content: String): String?
+
+ /**
+ * Get the number of digits required in the final OTP.
+ */
+ fun findDigits(content: String): String
+
+ /**
+ * Get the TOTP timeout period.
+ */
+ fun findPeriod(content: String): Long
+
+ /**
+ * Get the algorithm for the TOTP secret.
+ */
+ fun findAlgorithm(content: String): String
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt b/app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt
new file mode 100644
index 00000000..21910a3a
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.util.totp
+
+import android.net.Uri
+
+/**
+ * [Uri] backed TOTP URL parser.
+ */
+class UriTotpFinder : TotpFinder {
+
+ override fun findSecret(content: String): String? {
+ content.split("\n".toRegex()).forEach { line ->
+ if (line.startsWith(TOTP_FIELDS[0])) {
+ return Uri.parse(line).getQueryParameter("secret")
+ }
+ if (line.startsWith(TOTP_FIELDS[1], ignoreCase = true)) {
+ return line.split(": *".toRegex(), 2).toTypedArray()[1]
+ }
+ }
+ return null
+ }
+
+ override fun findDigits(content: String): String {
+ content.split("\n".toRegex()).forEach { line ->
+ if (line.startsWith(TOTP_FIELDS[0]) &&
+ Uri.parse(line).getQueryParameter("digits") != null) {
+ return Uri.parse(line).getQueryParameter("digits")!!
+ }
+ }
+ return "6"
+ }
+
+ override fun findPeriod(content: String): Long {
+ content.split("\n".toRegex()).forEach { line ->
+ if (line.startsWith(TOTP_FIELDS[0]) &&
+ Uri.parse(line).getQueryParameter("period") != null) {
+ val period = Uri.parse(line).getQueryParameter("period")!!.toLongOrNull()
+ if (period != null && period > 0)
+ return period
+ }
+ }
+ return 30
+ }
+
+ override fun findAlgorithm(content: String): String {
+ content.split("\n".toRegex()).forEach { line ->
+ if (line.startsWith(TOTP_FIELDS[0]) &&
+ Uri.parse(line).getQueryParameter("algorithm") != null) {
+ return Uri.parse(line).getQueryParameter("algorithm")!!
+ }
+ }
+ return "sha1"
+ }
+
+ companion object {
+
+ val TOTP_FIELDS = arrayOf(
+ "otpauth://totp",
+ "totp:"
+ )
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt b/app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt
new file mode 100644
index 00000000..6cd47b4b
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt
@@ -0,0 +1,474 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.viewmodel
+
+import android.app.Application
+import android.os.Parcelable
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.VisibleForTesting
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.asFlow
+import androidx.lifecycle.asLiveData
+import androidx.recyclerview.selection.ItemDetailsLookup
+import androidx.recyclerview.selection.ItemKeyProvider
+import androidx.recyclerview.selection.Selection
+import androidx.recyclerview.selection.SelectionPredicates
+import androidx.recyclerview.selection.SelectionTracker
+import androidx.recyclerview.selection.StorageStrategy
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import dev.msfjarvis.aps.util.autofill.AutofillPreferences
+import dev.msfjarvis.aps.util.autofill.DirectoryStructure
+import dev.msfjarvis.aps.data.password.PasswordItem
+import dev.msfjarvis.aps.data.repo.PasswordRepository
+import dev.msfjarvis.aps.util.settings.PasswordSortOrder
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.extensions.sharedPrefs
+import java.io.File
+import java.text.Collator
+import java.util.Locale
+import java.util.Stack
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.yield
+import me.zhanghai.android.fastscroll.PopupTextProvider
+
+private fun File.toPasswordItem() = if (isFile)
+ PasswordItem.newPassword(name, this, PasswordRepository.getRepositoryDirectory())
+else
+ PasswordItem.newCategory(name, this, PasswordRepository.getRepositoryDirectory())
+
+private fun PasswordItem.fuzzyMatch(filter: String): Int {
+ var i = 0
+ var j = 0
+ var score = 0
+ var bonus = 0
+ var bonusIncrement = 0
+
+ val toMatch = longName
+
+ while (i < filter.length && j < toMatch.length) {
+ when {
+ filter[i].isWhitespace() -> i++
+ filter[i].toLowerCase() == toMatch[j].toLowerCase() -> {
+ i++
+ bonusIncrement += 1
+ bonus += bonusIncrement
+ score += bonus
+ }
+ else -> {
+ bonus = 0
+ bonusIncrement = 0
+ }
+ }
+ j++
+ }
+ return if (i == filter.length) score else 0
+}
+
+private val CaseInsensitiveComparator = Collator.getInstance().apply {
+ strength = Collator.PRIMARY
+}
+
+private fun PasswordItem.Companion.makeComparator(
+ typeSortOrder: PasswordSortOrder,
+ directoryStructure: DirectoryStructure
+): Comparator<PasswordItem> {
+ return when (typeSortOrder) {
+ PasswordSortOrder.FOLDER_FIRST -> compareBy { it.type }
+ // In order to let INDEPENDENT not distinguish between items based on their type, we simply
+ // declare them all equal at this stage.
+ PasswordSortOrder.INDEPENDENT -> Comparator { _, _ -> 0 }
+ PasswordSortOrder.FILE_FIRST -> compareByDescending { it.type }
+ PasswordSortOrder.RECENTLY_USED -> PasswordSortOrder.RECENTLY_USED.comparator
+ }
+ .then(compareBy(nullsLast(CaseInsensitiveComparator)) {
+ directoryStructure.getIdentifierFor(it.file)
+ })
+ .then(compareBy(nullsLast(CaseInsensitiveComparator)) {
+ directoryStructure.getUsernameFor(it.file)
+ })
+}
+
+val PasswordItem.stableId: String
+ get() = file.absolutePath
+
+enum class FilterMode {
+ NoFilter,
+ StrictDomain,
+ Fuzzy
+}
+
+enum class SearchMode {
+ RecursivelyInSubdirectories,
+ InCurrentDirectoryOnly
+}
+
+enum class ListMode {
+ FilesOnly,
+ DirectoriesOnly,
+ AllEntries
+}
+
+@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
+class SearchableRepositoryViewModel(application: Application) : AndroidViewModel(application) {
+
+ private var _updateCounter = 0
+ private val updateCounter: Int
+ get() = _updateCounter
+
+ private fun forceUpdateOnNextSearchAction() {
+ _updateCounter++
+ }
+
+ private val root
+ get() = PasswordRepository.getRepositoryDirectory()
+ private val settings by lazy(LazyThreadSafetyMode.NONE) { application.sharedPrefs }
+ private val showHiddenContents
+ get() = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false)
+ private val defaultSearchMode
+ get() = if (settings.getBoolean(PreferenceKeys.FILTER_RECURSIVELY, true)) {
+ SearchMode.RecursivelyInSubdirectories
+ } else {
+ SearchMode.InCurrentDirectoryOnly
+ }
+
+ private val typeSortOrder
+ get() = PasswordSortOrder.getSortOrder(settings)
+ private val directoryStructure
+ get() = AutofillPreferences.directoryStructure(getApplication())
+ private val itemComparator
+ get() = PasswordItem.makeComparator(typeSortOrder, directoryStructure)
+
+ private data class SearchAction(
+ val baseDirectory: File,
+ val filter: String,
+ val filterMode: FilterMode,
+ val searchMode: SearchMode,
+ val listMode: ListMode,
+ // This counter can be increased to force a reexecution of the search action even if all
+ // other arguments are left unchanged.
+ val updateCounter: Int
+ )
+
+ private fun makeSearchAction(
+ baseDirectory: File,
+ filter: String,
+ filterMode: FilterMode,
+ searchMode: SearchMode,
+ listMode: ListMode
+ ): SearchAction {
+ return SearchAction(
+ baseDirectory = baseDirectory,
+ filter = filter,
+ filterMode = filterMode,
+ searchMode = searchMode,
+ listMode = listMode,
+ updateCounter = updateCounter
+ )
+ }
+
+ private fun updateSearchAction(action: SearchAction) =
+ action.copy(updateCounter = updateCounter)
+
+ private val searchAction = MutableLiveData(
+ makeSearchAction(
+ baseDirectory = root,
+ filter = "",
+ filterMode = FilterMode.NoFilter,
+ searchMode = SearchMode.InCurrentDirectoryOnly,
+ listMode = ListMode.AllEntries
+ )
+ )
+ private val searchActionFlow = searchAction.asFlow().distinctUntilChanged()
+
+ data class SearchResult(val passwordItems: List<PasswordItem>, val isFiltered: Boolean)
+
+ val searchResult = searchActionFlow
+ .mapLatest { searchAction ->
+ val listResultFlow = when (searchAction.searchMode) {
+ SearchMode.RecursivelyInSubdirectories -> listFilesRecursively(searchAction.baseDirectory)
+ SearchMode.InCurrentDirectoryOnly -> listFiles(searchAction.baseDirectory)
+ }
+ val prefilteredResultFlow = when (searchAction.listMode) {
+ ListMode.FilesOnly -> listResultFlow.filter { it.isFile }
+ ListMode.DirectoriesOnly -> listResultFlow.filter { it.isDirectory }
+ ListMode.AllEntries -> listResultFlow
+ }
+ val filterModeToUse =
+ if (searchAction.filter == "") FilterMode.NoFilter else searchAction.filterMode
+ val passwordList = when (filterModeToUse) {
+ FilterMode.NoFilter -> {
+ prefilteredResultFlow
+ .map { it.toPasswordItem() }
+ .toList()
+ .sortedWith(itemComparator)
+ }
+ FilterMode.StrictDomain -> {
+ check(searchAction.listMode == ListMode.FilesOnly) { "Searches with StrictDomain search mode can only list files" }
+ val regex = generateStrictDomainRegex(searchAction.filter)
+ if (regex != null) {
+ prefilteredResultFlow
+ .filter { absoluteFile ->
+ regex.containsMatchIn(absoluteFile.relativeTo(root).path)
+ }
+ .map { it.toPasswordItem() }
+ .toList()
+ .sortedWith(itemComparator)
+ } else {
+ emptyList()
+ }
+ }
+ FilterMode.Fuzzy -> {
+ prefilteredResultFlow
+ .map {
+ val item = it.toPasswordItem()
+ Pair(item.fuzzyMatch(searchAction.filter), item)
+ }
+ .filter { it.first > 0 }
+ .toList()
+ .sortedWith(
+ compareByDescending<Pair<Int, PasswordItem>> { it.first }.thenBy(
+ itemComparator
+ ) { it.second })
+ .map { it.second }
+ }
+ }
+ SearchResult(passwordList, isFiltered = searchAction.filterMode != FilterMode.NoFilter)
+ }.asLiveData(Dispatchers.IO)
+
+ private fun shouldTake(file: File) = with(file) {
+ if (showHiddenContents) return true
+ if (isDirectory) {
+ !isHidden
+ } else {
+ !isHidden && file.extension == "gpg"
+ }
+ }
+
+ private fun listFiles(dir: File): Flow<File> {
+ return dir.listFiles { file -> shouldTake(file) }?.asFlow() ?: emptyFlow()
+ }
+
+ private fun listFilesRecursively(dir: File): Flow<File> {
+ return dir
+ // Take top directory even if it is hidden.
+ .walkTopDown().onEnter { file -> file == dir || shouldTake(file) }
+ .asFlow()
+ // Skip the root directory
+ .drop(1)
+ .map {
+ yield()
+ it
+ }
+ .filter { file -> shouldTake(file) }
+ }
+
+ private val _currentDir = MutableLiveData(root)
+ val currentDir = _currentDir as LiveData<File>
+
+ data class NavigationStackEntry(val dir: File, val recyclerViewState: Parcelable?)
+
+ private val navigationStack = Stack<NavigationStackEntry>()
+
+ fun navigateTo(
+ newDirectory: File = root,
+ listMode: ListMode = ListMode.AllEntries,
+ recyclerViewState: Parcelable? = null,
+ pushPreviousLocation: Boolean = true
+ ) {
+ if (!newDirectory.exists()) return
+ require(newDirectory.isDirectory) { "Can only navigate to a directory" }
+ if (pushPreviousLocation) {
+ navigationStack.push(NavigationStackEntry(_currentDir.value!!, recyclerViewState))
+ }
+ searchAction.postValue(
+ makeSearchAction(
+ filter = "",
+ baseDirectory = newDirectory,
+ filterMode = FilterMode.NoFilter,
+ searchMode = SearchMode.InCurrentDirectoryOnly,
+ listMode = listMode
+ )
+ )
+ _currentDir.postValue(newDirectory)
+ }
+
+ val canNavigateBack
+ get() = navigationStack.isNotEmpty()
+
+ /**
+ * Navigate back to the last location on the [navigationStack] and restore a cached scroll
+ * position if possible.
+ *
+ * Returns the old RecyclerView's LinearLayoutManager state as a [Parcelable] if it was cached.
+ */
+ fun navigateBack(): Parcelable? {
+ if (!canNavigateBack) return null
+ val (oldDir, oldRecyclerViewState) = navigationStack.pop()
+ navigateTo(oldDir, pushPreviousLocation = false)
+ return oldRecyclerViewState
+ }
+
+ fun reset() {
+ navigationStack.clear()
+ forceUpdateOnNextSearchAction()
+ navigateTo(pushPreviousLocation = false)
+ }
+
+ fun search(
+ filter: String,
+ baseDirectory: File? = null,
+ filterMode: FilterMode = FilterMode.Fuzzy,
+ searchMode: SearchMode? = null,
+ listMode: ListMode = ListMode.AllEntries
+ ) {
+ require(baseDirectory?.isDirectory != false) { "Can only search in a directory" }
+ searchAction.postValue(
+ makeSearchAction(
+ filter = filter,
+ baseDirectory = baseDirectory ?: _currentDir.value!!,
+ filterMode = filterMode,
+ searchMode = searchMode ?: defaultSearchMode,
+ listMode = listMode
+ )
+ )
+ }
+
+ fun forceRefresh() {
+ forceUpdateOnNextSearchAction()
+ searchAction.postValue(updateSearchAction(searchAction.value!!))
+ }
+
+ companion object {
+
+ @VisibleForTesting
+ fun generateStrictDomainRegex(domain: String): Regex? {
+ // Valid domains do not contain path separators.
+ if (domain.contains('/'))
+ return null
+ // Matches the start of a path component, which is either the start of the
+ // string or a path separator.
+ val prefix = """(?:^|/)"""
+ val escapedFilter = Regex.escape(domain.replace("/", ""))
+ // Matches either the filter literally or a strict subdomain of the filter term.
+ // We allow a lot of freedom in what a subdomain is, as long as it is not an
+ // email address.
+ val subdomain = """(?:(?:[^/@]+\.)?$escapedFilter)"""
+ // Matches the end of a path component, which is either the literal ".gpg" or a
+ // path separator.
+ val suffix = """(?:\.gpg|/)"""
+ // Match any relative path with a component that is a subdomain of the filter.
+ return Regex(prefix + subdomain + suffix)
+ }
+ }
+}
+
+private object PasswordItemDiffCallback : DiffUtil.ItemCallback<PasswordItem>() {
+
+ override fun areItemsTheSame(oldItem: PasswordItem, newItem: PasswordItem) =
+ oldItem.file.absolutePath == newItem.file.absolutePath
+
+ override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) =
+ oldItem == newItem
+}
+
+open class SearchableRepositoryAdapter<T : RecyclerView.ViewHolder>(
+ private val layoutRes: Int,
+ private val viewHolderCreator: (view: View) -> T,
+ private val viewHolderBinder: T.(item: PasswordItem) -> Unit
+) : ListAdapter<PasswordItem, T>(PasswordItemDiffCallback), PopupTextProvider {
+
+ fun <T : ItemDetailsLookup<String>> makeSelectable(
+ recyclerView: RecyclerView,
+ itemDetailsLookupCreator: (recyclerView: RecyclerView) -> T
+ ) {
+ selectionTracker = SelectionTracker.Builder(
+ "SearchableRepositoryAdapter",
+ recyclerView,
+ itemKeyProvider,
+ itemDetailsLookupCreator(recyclerView),
+ StorageStrategy.createStringStorage()
+ ).withSelectionPredicate(SelectionPredicates.createSelectAnything()).build().apply {
+ addObserver(object : SelectionTracker.SelectionObserver<String>() {
+ override fun onSelectionChanged() {
+ this@SearchableRepositoryAdapter.onSelectionChangedListener?.invoke(
+ requireSelectionTracker().selection
+ )
+ }
+ })
+ }
+ }
+
+ private var onItemClickedListener: ((holder: T, item: PasswordItem) -> Unit)? = null
+ open fun onItemClicked(listener: (holder: T, item: PasswordItem) -> Unit): SearchableRepositoryAdapter<T> {
+ check(onItemClickedListener == null) { "Only a single listener can be registered for onItemClicked" }
+ onItemClickedListener = listener
+ return this
+ }
+
+ private var onSelectionChangedListener: ((selection: Selection<String>) -> Unit)? = null
+ open fun onSelectionChanged(listener: (selection: Selection<String>) -> Unit): SearchableRepositoryAdapter<T> {
+ check(onSelectionChangedListener == null) { "Only a single listener can be registered for onSelectionChanged" }
+ onSelectionChangedListener = listener
+ return this
+ }
+
+ private val itemKeyProvider = object : ItemKeyProvider<String>(SCOPE_MAPPED) {
+ override fun getKey(position: Int) = getItem(position).stableId
+
+ override fun getPosition(key: String) =
+ (0 until itemCount).firstOrNull { getItem(it).stableId == key }
+ ?: RecyclerView.NO_POSITION
+ }
+
+ private var selectionTracker: SelectionTracker<String>? = null
+ fun requireSelectionTracker() = selectionTracker!!
+
+ private val selectedFiles
+ get() = requireSelectionTracker().selection.map { File(it) }
+
+ fun getSelectedItems() = selectedFiles.map { it.toPasswordItem() }
+
+ fun getPositionForFile(file: File) = itemKeyProvider.getPosition(file.absolutePath)
+
+ final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T {
+ val view = LayoutInflater.from(parent.context)
+ .inflate(layoutRes, parent, false)
+ return viewHolderCreator(view)
+ }
+
+ final override fun onBindViewHolder(holder: T, position: Int) {
+ val item = getItem(position)
+ holder.apply {
+ viewHolderBinder.invoke(this, item)
+ selectionTracker?.let { itemView.isSelected = it.isSelected(item.stableId) }
+ itemView.setOnClickListener {
+ // Do not emit custom click events while the user is selecting items.
+ if (selectionTracker?.hasSelection() != true)
+ onItemClickedListener?.invoke(holder, item)
+ }
+ }
+ }
+
+ final override fun getPopupText(position: Int): String {
+ return getItem(position).name[0].toString().toUpperCase(Locale.getDefault())
+ }
+}