aboutsummaryrefslogtreecommitdiff
path: root/app/src/main/java/dev
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/dev')
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/Application.kt93
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/data/crypto/CryptoRepository.kt55
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/data/password/FieldItem.kt43
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/data/password/PasswordItem.kt77
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/data/repo/PasswordRepository.kt176
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/injection/context/ContextModule.kt27
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/injection/context/FilesDirPath.kt7
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/injection/coroutines/DispatcherModule.kt19
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/injection/crypto/CryptoHandlerModule.kt18
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/injection/crypto/KeyManagerModule.kt39
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/injection/prefs/GitPreferences.kt9
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/injection/prefs/PasswordGeneratorPreferences.kt10
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/injection/prefs/PreferenceModule.kt60
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/injection/prefs/ProxyPreferences.kt9
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/injection/prefs/SettingsPreferences.kt5
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/injection/pwgen/DicewareModule.kt55
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/injection/totp/TotpModule.kt19
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/adapters/FieldItemAdapter.kt92
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/adapters/PasswordItemRecyclerAdapter.kt94
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt269
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivityV2.kt180
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterView.kt241
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillPublisherChangedActivity.kt135
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillSaveActivity.kt173
-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.kt290
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt227
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivityV2.kt209
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt78
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt617
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivityV2.kt479
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordDialog.kt68
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt166
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt90
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/dialogs/FolderCreationDialogFragment.kt110
-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/OtpImportDialogFragment.kt47
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt149
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderActivity.kt65
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderFragment.kt86
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/git/base/BaseGitActivity.kt185
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitConfigActivity.kt159
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitServerConfigActivity.kt305
-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.kt59
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/main/LaunchActivity.kt87
-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.kt78
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/KeySelectionFragment.kt74
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/WelcomeFragment.kt33
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt382
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt639
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/pgp/PGPKeyImportActivity.kt70
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/proxy/ProxySelectorActivity.kt94
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/settings/AutofillSettings.kt133
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/settings/GeneralSettings.kt110
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/settings/MiscSettings.kt81
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/settings/PGPSettings.kt37
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/settings/PasswordSettings.kt53
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/settings/RepositorySettings.kt194
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsActivity.kt106
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsProvider.kt15
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/ShowSshKeyFragment.kt39
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyGenActivity.kt172
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyImportActivity.kt66
-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.kt131
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt236
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt202
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt148
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt233
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt120
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/crypto/GpgIdentifier.kt42
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt133
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt98
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt41
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt70
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/features/Feature.kt23
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/features/Features.kt21
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt66
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt133
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/GitCommit.kt23
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt60
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt66
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt24
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt116
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/GcOperation.kt22
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt245
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt35
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt17
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt27
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt27
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt34
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt225
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt103
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt372
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt289
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt218
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt71
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt206
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt172
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt159
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt186
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt154
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt53
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt88
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/shortcuts/ShortcutHandler.kt106
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt53
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt473
109 files changed, 0 insertions, 13317 deletions
diff --git a/app/src/main/java/dev/msfjarvis/aps/Application.kt b/app/src/main/java/dev/msfjarvis/aps/Application.kt
deleted file mode 100644
index 6aa6e53b..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/Application.kt
+++ /dev/null
@@ -1,93 +0,0 @@
-/*
- * Copyright © 2014-2021 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.google.android.material.color.DynamicColors
-import dagger.hilt.android.HiltAndroidApp
-import dev.msfjarvis.aps.injection.context.FilesDirPath
-import dev.msfjarvis.aps.injection.prefs.SettingsPreferences
-import dev.msfjarvis.aps.util.extensions.getString
-import dev.msfjarvis.aps.util.features.Feature
-import dev.msfjarvis.aps.util.features.Features
-import dev.msfjarvis.aps.util.git.sshj.setUpBouncyCastleForSshj
-import dev.msfjarvis.aps.util.proxy.ProxyUtils
-import dev.msfjarvis.aps.util.settings.GitSettings
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import dev.msfjarvis.aps.util.settings.runMigrations
-import io.sentry.Sentry
-import io.sentry.protocol.User
-import javax.inject.Inject
-import logcat.AndroidLogcatLogger
-import logcat.LogPriority.DEBUG
-import logcat.LogcatLogger
-
-@Suppress("Unused")
-@HiltAndroidApp
-class Application : android.app.Application(), SharedPreferences.OnSharedPreferenceChangeListener {
-
- @Inject @SettingsPreferences lateinit var prefs: SharedPreferences
- @Inject @FilesDirPath lateinit var filesDirPath: String
- @Inject lateinit var proxyUtils: ProxyUtils
- @Inject lateinit var gitSettings: GitSettings
- @Inject lateinit var features: Features
-
- override fun onCreate() {
- super.onCreate()
- instance = this
- if (
- BuildConfig.ENABLE_DEBUG_FEATURES ||
- prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false)
- ) {
- LogcatLogger.install(AndroidLogcatLogger(DEBUG))
- }
- prefs.registerOnSharedPreferenceChangeListener(this)
- setNightMode()
- setUpBouncyCastleForSshj()
- runMigrations(filesDirPath, prefs, gitSettings)
- proxyUtils.setDefaultProxy()
- DynamicColors.applyToActivitiesIfAvailable(this)
- Sentry.configureScope { scope ->
- val user = User()
- user.others =
- Feature.VALUES.associate { feature ->
- "features.${feature.configKey}" to features.isEnabled(feature).toString()
- }
- scope.user = user
- }
- }
-
- 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/crypto/CryptoRepository.kt b/app/src/main/java/dev/msfjarvis/aps/data/crypto/CryptoRepository.kt
deleted file mode 100644
index 0e262e7a..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/data/crypto/CryptoRepository.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.data.crypto
-
-import com.github.michaelbull.result.unwrap
-import dev.msfjarvis.aps.crypto.PGPKeyManager
-import dev.msfjarvis.aps.crypto.PGPainlessCryptoHandler
-import dev.msfjarvis.aps.util.extensions.isOk
-import java.io.ByteArrayInputStream
-import java.io.ByteArrayOutputStream
-import javax.inject.Inject
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-
-class CryptoRepository
-@Inject
-constructor(
- private val pgpKeyManager: PGPKeyManager,
- private val pgpCryptoHandler: PGPainlessCryptoHandler,
-) {
-
- suspend fun decrypt(
- password: String,
- message: ByteArrayInputStream,
- out: ByteArrayOutputStream,
- ) {
- withContext(Dispatchers.IO) { decryptPgp(password, message, out) }
- }
-
- suspend fun encrypt(content: ByteArrayInputStream, out: ByteArrayOutputStream) {
- withContext(Dispatchers.IO) { encryptPgp(content, out) }
- }
-
- private suspend fun decryptPgp(
- password: String,
- message: ByteArrayInputStream,
- out: ByteArrayOutputStream,
- ) {
- val keys = pgpKeyManager.getAllKeys().unwrap()
- // Iterates through the keys until the first successful decryption, then returns.
- keys.firstOrNull { key -> pgpCryptoHandler.decrypt(key, password, message, out).isOk() }
- }
-
- private suspend fun encryptPgp(content: ByteArrayInputStream, out: ByteArrayOutputStream) {
- val keys = pgpKeyManager.getAllKeys().unwrap()
- pgpCryptoHandler.encrypt(
- keys,
- content,
- out,
- )
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/data/password/FieldItem.kt b/app/src/main/java/dev/msfjarvis/aps/data/password/FieldItem.kt
deleted file mode 100644
index e7c88ef1..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/data/password/FieldItem.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-/*
- * Copyright © 2014-2021 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.data.passfile.Totp
-import kotlin.time.ExperimentalTime
-
-@OptIn(ExperimentalTime::class)
-class FieldItem(val key: String, val value: String, val action: ActionType) {
- enum class ActionType {
- COPY,
- HIDE
- }
-
- enum class ItemType(val type: String, val label: String) {
- USERNAME("Username", "Username"),
- PASSWORD("Password", "Password"),
- OTP("OTP", "OTP (expires in %ds)"),
- }
-
- companion object {
-
- // Extra helper methods
- fun createOtpField(totp: Totp): FieldItem {
- return FieldItem(
- ItemType.OTP.label.format(totp.remainingTime.inWholeSeconds),
- totp.value,
- ActionType.COPY,
- )
- }
-
- fun createPasswordField(password: String): FieldItem {
- return FieldItem(ItemType.PASSWORD.label, password, ActionType.HIDE)
- }
-
- fun createUsernameField(username: String): FieldItem {
- return FieldItem(ItemType.USERNAME.label, username, ActionType.COPY)
- }
- }
-}
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
deleted file mode 100644
index 82b0dc35..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordItem.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.data.password
-
-import android.content.Context
-import android.content.Intent
-import dev.msfjarvis.aps.data.repo.PasswordRepository
-import dev.msfjarvis.aps.ui.crypto.BasePgpActivity
-import dev.msfjarvis.aps.ui.main.LaunchActivity
-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
- }
-
- /** Creates an [Intent] to launch this [PasswordItem] through the authentication process. */
- fun createAuthEnabledIntent(context: Context): Intent {
- val intent = Intent(context, LaunchActivity::class.java)
- intent.putExtra("NAME", toString())
- intent.putExtra("FILE_PATH", file.absolutePath)
- intent.putExtra("REPO_PATH", PasswordRepository.getRepositoryDirectory().absolutePath)
- intent.action = LaunchActivity.ACTION_DECRYPT_PASS
- return intent
- }
-
- 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
deleted file mode 100644
index f675e80a..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/data/repo/PasswordRepository.kt
+++ /dev/null
@@ -1,176 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.data.repo
-
-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.extensions.sharedPrefs
-import dev.msfjarvis.aps.util.extensions.unsafeLazy
-import dev.msfjarvis.aps.util.settings.PasswordSortOrder
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import java.io.File
-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
-
-object PasswordRepository {
-
- var repository: Repository? = null
- private val settings by unsafeLazy { Application.instance.sharedPrefs }
- private val filesDir
- get() = Application.instance.filesDir
- val isInitialized: Boolean
- get() = repository != null
-
- fun isGitRepo(): Boolean {
- return repository?.objectDatabase?.exists() ?: false
- }
-
- /**
- * Takes in a [repositoryDir] to initialize a Git repository with, and assigns it to [repository]
- * as static state.
- */
- private fun initializeRepository(repositoryDir: File) {
- val builder = FileRepositoryBuilder()
- repository =
- runCatching { builder.setGitDir(repositoryDir).build() }
- .getOrElse { e ->
- e.printStackTrace()
- null
- }
- }
-
- fun createRepository(repositoryDir: File) {
- repositoryDir.delete()
- Git.init().setDirectory(repositoryDir).call()
- initializeRepository(repositoryDir)
- }
-
- // TODO add multiple remotes support for pull/push
- 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() }
- }
- }
-
- fun closeRepository() {
- repository?.close()
- repository = null
- }
-
- fun getRepositoryDirectory(): File {
- return File(filesDir.toString(), "/store")
- }
-
- fun initialize(): Repository? {
- val dir = getRepositoryDirectory()
- // Un-initialize 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
- initializeRepository(dir.resolve(".git"))
-
- return repository
- }
-
- /**
- * Gets the .gpg files in a directory
- *
- * @param path the directory path
- * @return the list of gpg files in that directory
- */
- private fun getFilesList(path: File): ArrayList<File> {
- if (!path.exists()) return ArrayList()
- val files =
- (path.listFiles { file -> file.isDirectory || file.extension == "gpg" } ?: emptyArray())
- .toList()
- val items = ArrayList<File>()
- items.addAll(files)
- return items
- }
-
- /**
- * Gets the passwords (PasswordItem) in a directory
- *
- * @param path the directory path
- * @return a list of password items
- */
- 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/injection/context/ContextModule.kt b/app/src/main/java/dev/msfjarvis/aps/injection/context/ContextModule.kt
deleted file mode 100644
index 41e59bf9..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/injection/context/ContextModule.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package dev.msfjarvis.aps.injection.context
-
-import android.content.Context
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.android.qualifiers.ApplicationContext
-import dagger.hilt.components.SingletonComponent
-
-@Module
-@InstallIn(SingletonComponent::class)
-class ContextModule {
-
- /**
- * We inject [Context.getFilesDir] to break the dependency on [Context], allowing tests to run on
- * the JVM. The principle here is identical to why [dev.msfjarvis.aps.util.totp.TotpFinder]
- * exists.
- *
- * @param context [ApplicationContext]
- * @return the path of app-specific files directory.
- */
- @Provides
- @FilesDirPath
- fun providesFilesDirPath(@ApplicationContext context: Context): String {
- return context.filesDir.path
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/context/FilesDirPath.kt b/app/src/main/java/dev/msfjarvis/aps/injection/context/FilesDirPath.kt
deleted file mode 100644
index f6419354..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/injection/context/FilesDirPath.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package dev.msfjarvis.aps.injection.context
-
-import android.content.Context
-import javax.inject.Qualifier
-
-/** Qualifies a [String] representing the absolute path of [Context.getFilesDir]. */
-@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class FilesDirPath
diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/coroutines/DispatcherModule.kt b/app/src/main/java/dev/msfjarvis/aps/injection/coroutines/DispatcherModule.kt
deleted file mode 100644
index bf84fc27..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/injection/coroutines/DispatcherModule.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.injection.coroutines
-
-import dagger.Binds
-import dagger.Module
-import dagger.hilt.InstallIn
-import dagger.hilt.components.SingletonComponent
-import dev.msfjarvis.aps.util.coroutines.DefaultDispatcherProvider
-import dev.msfjarvis.aps.util.coroutines.DispatcherProvider
-
-@Module
-@InstallIn(SingletonComponent::class)
-interface DispatcherModule {
- @Binds fun DefaultDispatcherProvider.bind(): DispatcherProvider
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/crypto/CryptoHandlerModule.kt b/app/src/main/java/dev/msfjarvis/aps/injection/crypto/CryptoHandlerModule.kt
deleted file mode 100644
index ef6a11ce..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/injection/crypto/CryptoHandlerModule.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.injection.crypto
-
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.components.SingletonComponent
-import dev.msfjarvis.aps.crypto.PGPainlessCryptoHandler
-
-@Module
-@InstallIn(SingletonComponent::class)
-object CryptoHandlerModule {
- @Provides fun providePgpCryptoHandler() = PGPainlessCryptoHandler()
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/crypto/KeyManagerModule.kt b/app/src/main/java/dev/msfjarvis/aps/injection/crypto/KeyManagerModule.kt
deleted file mode 100644
index a1119a1c..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/injection/crypto/KeyManagerModule.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.injection.crypto
-
-import android.content.Context
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.android.qualifiers.ApplicationContext
-import dagger.hilt.components.SingletonComponent
-import dev.msfjarvis.aps.crypto.PGPKeyManager
-import dev.msfjarvis.aps.util.coroutines.DispatcherProvider
-import javax.inject.Qualifier
-
-@Module
-@InstallIn(SingletonComponent::class)
-object KeyManagerModule {
- @Provides
- fun providePGPKeyManager(
- @PGPKeyDir keyDir: String,
- dispatcherProvider: DispatcherProvider,
- ): PGPKeyManager {
- return PGPKeyManager(
- keyDir,
- dispatcherProvider.io(),
- )
- }
-
- @Provides
- @PGPKeyDir
- fun providePGPKeyDir(@ApplicationContext context: Context): String {
- return context.filesDir.resolve("pgp_keys").absolutePath
- }
-}
-
-@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class PGPKeyDir
diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/prefs/GitPreferences.kt b/app/src/main/java/dev/msfjarvis/aps/injection/prefs/GitPreferences.kt
deleted file mode 100644
index 2947a64d..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/injection/prefs/GitPreferences.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package dev.msfjarvis.aps.injection.prefs
-
-import android.content.SharedPreferences
-import javax.inject.Qualifier
-
-/**
- * Qualifies a [SharedPreferences] instance specifically used for encrypted Git-related settings.
- */
-@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class GitPreferences
diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/prefs/PasswordGeneratorPreferences.kt b/app/src/main/java/dev/msfjarvis/aps/injection/prefs/PasswordGeneratorPreferences.kt
deleted file mode 100644
index 14b3a6f2..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/injection/prefs/PasswordGeneratorPreferences.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.injection.prefs
-
-import javax.inject.Qualifier
-
-@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class PasswordGeneratorPreferences
diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/prefs/PreferenceModule.kt b/app/src/main/java/dev/msfjarvis/aps/injection/prefs/PreferenceModule.kt
deleted file mode 100644
index e68a998f..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/injection/prefs/PreferenceModule.kt
+++ /dev/null
@@ -1,60 +0,0 @@
-package dev.msfjarvis.aps.injection.prefs
-
-import android.content.Context
-import android.content.Context.MODE_PRIVATE
-import android.content.SharedPreferences
-import androidx.security.crypto.EncryptedSharedPreferences
-import androidx.security.crypto.MasterKey
-import dagger.Module
-import dagger.Provides
-import dagger.Reusable
-import dagger.hilt.InstallIn
-import dagger.hilt.android.qualifiers.ApplicationContext
-import dagger.hilt.components.SingletonComponent
-import dev.msfjarvis.aps.BuildConfig
-
-@Module
-@InstallIn(SingletonComponent::class)
-class PreferenceModule {
-
- private fun provideBaseEncryptedPreferences(
- context: Context,
- fileName: String
- ): SharedPreferences {
- val masterKeyAlias =
- MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
- return EncryptedSharedPreferences.create(
- context,
- fileName,
- masterKeyAlias,
- EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
- EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
- )
- }
-
- @[Provides PasswordGeneratorPreferences Reusable]
- fun providePwgenPreferences(@ApplicationContext context: Context): SharedPreferences {
- return provideBaseEncryptedPreferences(context, "pwgen_preferences")
- }
-
- @Provides
- @SettingsPreferences
- @Reusable
- fun provideSettingsPreferences(@ApplicationContext context: Context): SharedPreferences {
- return context.getSharedPreferences("${BuildConfig.APPLICATION_ID}_preferences", MODE_PRIVATE)
- }
-
- @Provides
- @GitPreferences
- @Reusable
- fun provideEncryptedPreferences(@ApplicationContext context: Context): SharedPreferences {
- return provideBaseEncryptedPreferences(context, "git_operation")
- }
-
- @Provides
- @ProxyPreferences
- @Reusable
- fun provideProxyPreferences(@ApplicationContext context: Context): SharedPreferences {
- return provideBaseEncryptedPreferences(context, "http_proxy")
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/prefs/ProxyPreferences.kt b/app/src/main/java/dev/msfjarvis/aps/injection/prefs/ProxyPreferences.kt
deleted file mode 100644
index 5fa99140..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/injection/prefs/ProxyPreferences.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package dev.msfjarvis.aps.injection.prefs
-
-import android.content.SharedPreferences
-import javax.inject.Qualifier
-
-/**
- * Qualifies a [SharedPreferences] instance specifically used for encrypted proxy-related settings.
- */
-@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class ProxyPreferences
diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/prefs/SettingsPreferences.kt b/app/src/main/java/dev/msfjarvis/aps/injection/prefs/SettingsPreferences.kt
deleted file mode 100644
index 7bca03c8..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/injection/prefs/SettingsPreferences.kt
+++ /dev/null
@@ -1,5 +0,0 @@
-package dev.msfjarvis.aps.injection.prefs
-
-import javax.inject.Qualifier
-
-@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class SettingsPreferences
diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/pwgen/DicewareModule.kt b/app/src/main/java/dev/msfjarvis/aps/injection/pwgen/DicewareModule.kt
deleted file mode 100644
index 8aed12de..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/injection/pwgen/DicewareModule.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.injection.pwgen
-
-import android.content.Context
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.android.components.FragmentComponent
-import dagger.hilt.android.qualifiers.ApplicationContext
-import dev.msfjarvis.aps.passgen.diceware.DicewarePassphraseGenerator
-import dev.msfjarvis.aps.passgen.diceware.Die
-import dev.msfjarvis.aps.passgen.diceware.RandomIntGenerator
-import java.io.InputStream
-import java.security.SecureRandom
-import javax.inject.Qualifier
-
-@Module
-@InstallIn(FragmentComponent::class)
-object DicewareModule {
-
- @Provides
- fun provideDicewareGenerator(
- die: Die,
- @WordlistQualifier wordList: InputStream,
- ): DicewarePassphraseGenerator {
- return DicewarePassphraseGenerator(die, wordList)
- }
-
- @Provides
- fun provideDie(
- intGenerator: RandomIntGenerator,
- ): Die {
- return Die(6, intGenerator)
- }
-
- @Provides
- fun provideRandomIntGenerator(): RandomIntGenerator {
- return RandomIntGenerator { range ->
- SecureRandom().nextInt(range.last).coerceAtLeast(range.first)
- }
- }
-
- @[Provides WordlistQualifier]
- fun provideDefaultWordList(@ApplicationContext context: Context): InputStream {
- return context.resources.openRawResource(
- dev.msfjarvis.aps.passgen.diceware.R.raw.diceware_wordlist
- )
- }
-}
-
-@Qualifier annotation class WordlistQualifier
diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/totp/TotpModule.kt b/app/src/main/java/dev/msfjarvis/aps/injection/totp/TotpModule.kt
deleted file mode 100644
index 859559cd..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/injection/totp/TotpModule.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.injection.totp
-
-import dagger.Binds
-import dagger.Module
-import dagger.hilt.InstallIn
-import dagger.hilt.android.components.ActivityComponent
-import dev.msfjarvis.aps.util.totp.TotpFinder
-import dev.msfjarvis.aps.util.totp.UriTotpFinder
-
-@Module
-@InstallIn(ActivityComponent::class)
-interface TotpModule {
- @Binds fun UriTotpFinder.bind(): TotpFinder
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/adapters/FieldItemAdapter.kt b/app/src/main/java/dev/msfjarvis/aps/ui/adapters/FieldItemAdapter.kt
deleted file mode 100644
index ea96f961..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/adapters/FieldItemAdapter.kt
+++ /dev/null
@@ -1,92 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.ui.adapters
-
-import android.text.method.PasswordTransformationMethod
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.core.content.ContextCompat
-import androidx.recyclerview.widget.RecyclerView
-import com.google.android.material.textfield.TextInputLayout
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.data.passfile.Totp
-import dev.msfjarvis.aps.data.password.FieldItem
-import dev.msfjarvis.aps.databinding.ItemFieldBinding
-
-class FieldItemAdapter(
- private var fieldItemList: List<FieldItem>,
- private val showPassword: Boolean,
- private val copyTextToClipboard: (text: String?) -> Unit,
-) : RecyclerView.Adapter<FieldItemAdapter.FieldItemViewHolder>() {
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldItemViewHolder {
- val binding = ItemFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false)
- return FieldItemViewHolder(binding.root, binding)
- }
-
- override fun onBindViewHolder(holder: FieldItemViewHolder, position: Int) {
- holder.bind(fieldItemList[position], showPassword, copyTextToClipboard)
- }
-
- override fun getItemCount(): Int {
- return fieldItemList.size
- }
-
- fun updateOTPCode(totp: Totp) {
- var otpItemPosition = -1
- fieldItemList =
- fieldItemList.mapIndexed { position, item ->
- if (item.key.startsWith(FieldItem.ItemType.OTP.type, true)) {
- otpItemPosition = position
- return@mapIndexed FieldItem.createOtpField(totp)
- }
-
- return@mapIndexed item
- }
-
- notifyItemChanged(otpItemPosition)
- }
-
- class FieldItemViewHolder(itemView: View, val binding: ItemFieldBinding) :
- RecyclerView.ViewHolder(itemView) {
-
- fun bind(fieldItem: FieldItem, showPassword: Boolean, copyTextToClipboard: (String?) -> Unit) {
- with(binding) {
- itemText.hint = fieldItem.key
- itemTextContainer.hint = fieldItem.key
- itemText.setText(fieldItem.value)
-
- when (fieldItem.action) {
- FieldItem.ActionType.COPY -> {
- itemTextContainer.apply {
- endIconDrawable =
- ContextCompat.getDrawable(itemView.context, R.drawable.ic_content_copy)
- endIconMode = TextInputLayout.END_ICON_CUSTOM
- setEndIconOnClickListener { copyTextToClipboard(itemText.text.toString()) }
- }
- itemText.transformationMethod = null
- }
- FieldItem.ActionType.HIDE -> {
- itemTextContainer.apply {
- endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
- setOnClickListener { copyTextToClipboard(itemText.text.toString()) }
- }
- itemText.apply {
- transformationMethod =
- if (!showPassword) {
- PasswordTransformationMethod.getInstance()
- } else {
- null
- }
- setOnClickListener { copyTextToClipboard(itemText.text.toString()) }
- }
- }
- }
- }
- }
- }
-}
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
deleted file mode 100644
index be0267c4..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/adapters/PasswordItemRecyclerAdapter.kt
+++ /dev/null
@@ -1,94 +0,0 @@
-/*
- * Copyright © 2014-2021 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
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-
-open class PasswordItemRecyclerAdapter(coroutineScope: CoroutineScope) :
- SearchableRepositoryAdapter<PasswordItemRecyclerAdapter.PasswordItemViewHolder>(
- R.layout.password_row_layout,
- ::PasswordItemViewHolder,
- coroutineScope,
- 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>
-
- suspend 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 =
- withContext(Dispatchers.IO) {
- 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
deleted file mode 100644
index b5052049..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt
+++ /dev/null
@@ -1,269 +0,0 @@
-/*
- * Copyright © 2014-2021 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 androidx.lifecycle.lifecycleScope
-import com.github.androidpasswordstore.autofillparser.AutofillAction
-import com.github.androidpasswordstore.autofillparser.Credentials
-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 dagger.hilt.android.AndroidEntryPoint
-import dev.msfjarvis.aps.data.passfile.PasswordEntry
-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.util.extensions.OPENPGP_PROVIDER
-import dev.msfjarvis.aps.util.extensions.asLog
-import java.io.ByteArrayOutputStream
-import java.io.File
-import java.io.InputStream
-import java.io.OutputStream
-import javax.inject.Inject
-import kotlin.coroutines.Continuation
-import kotlin.coroutines.resume
-import kotlin.coroutines.resumeWithException
-import kotlin.coroutines.suspendCoroutine
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import logcat.LogPriority.ERROR
-import logcat.logcat
-import me.msfjarvis.openpgpktx.util.OpenPgpApi
-import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
-import org.openintents.openpgp.IOpenPgpService2
-import org.openintents.openpgp.OpenPgpError
-
-@RequiresApi(26)
-@AndroidEntryPoint
-class AutofillDecryptActivity : AppCompatActivity() {
-
- 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,
- if (Build.VERSION.SDK_INT >= 31) {
- PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE
- } else {
- PendingIntent.FLAG_CANCEL_CURRENT
- },
- )
- .intentSender
- }
- }
-
- @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
-
- 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 fun onStart() {
- super.onStart()
- val filePath =
- intent?.getStringExtra(EXTRA_FILE_PATH)
- ?: run {
- logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_FILE_PATH" }
- finish()
- return
- }
- val clientState =
- intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
- ?: run {
- logcat(ERROR) { "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)
- logcat { action.toString() }
- lifecycleScope.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() }
- }
- }
-
- 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 ->
- logcat(ERROR) { e.asLog("File to decrypt not found") }
- return null
- }
- .onSuccess { encryptedInput ->
- val decryptedOutput = ByteArrayOutputStream()
- runCatching { executeOpenPgpApi(command, encryptedInput, decryptedOutput) }
- .onFailure { e ->
- logcat(ERROR) { e.asLog("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")
- passwordEntryFactory.create(decryptedOutput.toByteArray())
- }
- AutofillPreferences.credentialsFromStoreEntry(
- this,
- file,
- entry,
- directoryStructure
- )
- }
- .getOrElse { e ->
- logcat(ERROR) { e.asLog("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 ->
- logcat(ERROR) {
- e.asLog("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()
- }
- logcat(ERROR) {
- "OpenPgpApi ACTION_DECRYPT_VERIFY failed (${error.errorId}): ${error.message}"
- }
- }
- null
- }
- else -> {
- logcat(ERROR) { "Unrecognized OpenPgpApi result: $resultCode" }
- null
- }
- }
- }
- }
- return null
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivityV2.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivityV2.kt
deleted file mode 100644
index 4fd8f026..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivityV2.kt
+++ /dev/null
@@ -1,180 +0,0 @@
-/*
- * Copyright © 2014-2021 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.annotation.RequiresApi
-import androidx.appcompat.app.AppCompatActivity
-import androidx.lifecycle.lifecycleScope
-import com.github.androidpasswordstore.autofillparser.AutofillAction
-import com.github.androidpasswordstore.autofillparser.Credentials
-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 dagger.hilt.android.AndroidEntryPoint
-import dev.msfjarvis.aps.data.crypto.CryptoRepository
-import dev.msfjarvis.aps.data.passfile.PasswordEntry
-import dev.msfjarvis.aps.ui.crypto.PasswordDialog
-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.util.extensions.asLog
-import java.io.ByteArrayOutputStream
-import java.io.File
-import javax.inject.Inject
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import logcat.LogPriority.ERROR
-import logcat.logcat
-
-@RequiresApi(26)
-@AndroidEntryPoint
-class AutofillDecryptActivityV2 : AppCompatActivity() {
-
- 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, AutofillDecryptActivityV2::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, AutofillDecryptActivityV2::class.java).apply {
- putExtra(EXTRA_SEARCH_ACTION, false)
- putExtra(EXTRA_FILE_PATH, file.absolutePath)
- }
- return PendingIntent.getActivity(
- context,
- decryptFileRequestCode++,
- intent,
- if (Build.VERSION.SDK_INT >= 31) {
- PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE
- } else {
- PendingIntent.FLAG_CANCEL_CURRENT
- },
- )
- .intentSender
- }
- }
-
- @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
- @Inject lateinit var repository: CryptoRepository
-
- private lateinit var directoryStructure: DirectoryStructure
-
- override fun onStart() {
- super.onStart()
- val filePath =
- intent?.getStringExtra(EXTRA_FILE_PATH)
- ?: run {
- logcat(ERROR) { "AutofillDecryptActivityV2 started without EXTRA_FILE_PATH" }
- finish()
- return
- }
- val clientState =
- intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
- ?: run {
- logcat(ERROR) { "AutofillDecryptActivityV2 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)
- logcat { action.toString() }
- val dialog = PasswordDialog()
- lifecycleScope.launch {
- withContext(Dispatchers.Main) {
- dialog.password.collectLatest { value ->
- if (value != null) {
- decrypt(File(filePath), clientState, action, value)
- }
- }
- }
- }
- dialog.show(supportFragmentManager, "PASSWORD_DIALOG")
- }
-
- private suspend fun decrypt(
- filePath: File,
- clientState: Bundle,
- action: AutofillAction,
- password: String,
- ) {
- val credentials = decryptCredential(filePath, password)
- if (credentials == null) {
- setResult(RESULT_CANCELED)
- } else {
- val fillInDataset =
- AutofillResponseBuilder.makeFillInDataset(
- this@AutofillDecryptActivityV2,
- credentials,
- clientState,
- action
- )
- withContext(Dispatchers.Main) {
- setResult(
- RESULT_OK,
- Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) }
- )
- }
- }
- withContext(Dispatchers.Main) { finish() }
- }
-
- private suspend fun decryptCredential(file: File, password: String): Credentials? {
- runCatching { file.readBytes().inputStream() }
- .onFailure { e ->
- logcat(ERROR) { e.asLog("File to decrypt not found") }
- return null
- }
- .onSuccess { encryptedInput ->
- runCatching {
- withContext(Dispatchers.IO) {
- val outputStream = ByteArrayOutputStream()
- repository.decrypt(
- password,
- encryptedInput,
- outputStream,
- )
- outputStream
- }
- }
- .onFailure { e ->
- logcat(ERROR) { e.asLog("Decryption failed") }
- return null
- }
- .onSuccess { result ->
- return runCatching {
- val entry = passwordEntryFactory.create(result.toByteArray())
- AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure)
- }
- .getOrElse { e ->
- logcat(ERROR) { e.asLog("Failed to parse password entry") }
- return null
- }
- }
- }
- return null
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterView.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterView.kt
deleted file mode 100644
index 89f1a733..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterView.kt
+++ /dev/null
@@ -1,241 +0,0 @@
-/*
- * Copyright © 2014-2021 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.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.LinearLayoutManager
-import com.github.androidpasswordstore.autofillparser.FormOrigin
-import dagger.hilt.android.AndroidEntryPoint
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.data.password.PasswordItem
-import dev.msfjarvis.aps.databinding.ActivityOreoAutofillFilterBinding
-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.util.extensions.viewBinding
-import dev.msfjarvis.aps.util.features.Feature
-import dev.msfjarvis.aps.util.features.Features
-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
-import javax.inject.Inject
-import logcat.LogPriority.ERROR
-import logcat.logcat
-
-@TargetApi(26)
-@AndroidEntryPoint
-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,
- if (Build.VERSION.SDK_INT >= 31) {
- PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE
- } else {
- PendingIntent.FLAG_CANCEL_CURRENT
- },
- )
- .intentSender
- }
- }
-
- @Inject lateinit var features: Features
- 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) {
- logcat(ERROR) { "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 -> {
- logcat(ERROR) {
- "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,
- lifecycleScope,
- ) { 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(
- if (features.isEnabled(Feature.EnablePGPainlessBackend)) {
- AutofillDecryptActivityV2.makeDecryptFileIntent(item.file, intent!!.extras!!, this)
- } else {
- 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
deleted file mode 100644
index 85911815..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillPublisherChangedActivity.kt
+++ /dev/null
@@ -1,135 +0,0 @@
-/*
- * Copyright © 2014-2021 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.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.databinding.ActivityOreoAutofillPublisherChangedBinding
-import dev.msfjarvis.aps.util.autofill.AutofillMatcher
-import dev.msfjarvis.aps.util.autofill.AutofillPublisherChangedException
-import dev.msfjarvis.aps.util.extensions.asLog
-import dev.msfjarvis.aps.util.extensions.viewBinding
-import logcat.LogPriority.ERROR
-import logcat.logcat
-
-@TargetApi(26)
-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,
- if (Build.VERSION.SDK_INT >= 31) {
- PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE
- } else {
- 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 {
- logcat(ERROR) { "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 =
- getString(
- R.string.oreo_autofill_warning_publisher_app_name,
- 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 ->
- logcat(ERROR) { e.asLog("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
deleted file mode 100644
index a963836e..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillSaveActivity.kt
+++ /dev/null
@@ -1,173 +0,0 @@
-/*
- * Copyright © 2014-2021 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.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.androidpasswordstore.autofillparser.AutofillAction
-import com.github.androidpasswordstore.autofillparser.Credentials
-import com.github.androidpasswordstore.autofillparser.FormOrigin
-import dagger.hilt.android.AndroidEntryPoint
-import dev.msfjarvis.aps.data.repo.PasswordRepository
-import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivity
-import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivityV2
-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.util.extensions.unsafeLazy
-import dev.msfjarvis.aps.util.features.Feature
-import dev.msfjarvis.aps.util.features.Features
-import java.io.File
-import javax.inject.Inject
-import logcat.LogPriority.ERROR
-import logcat.logcat
-
-@RequiresApi(26)
-@AndroidEntryPoint
-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 or PendingIntent.FLAG_IMMUTABLE
- )
- .intentSender
- }
- }
-
- private val formOrigin by unsafeLazy {
- 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
- }
- }
-
- @Inject lateinit var features: Features
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- val repo = PasswordRepository.getRepositoryDirectory()
- val creationActivity =
- if (features.isEnabled(Feature.EnablePGPainlessBackend))
- PasswordCreationActivityV2::class.java
- else PasswordCreationActivity::class.java
- val saveIntent =
- Intent(this, creationActivity).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 {
- logcat(ERROR) { "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
deleted file mode 100644
index eacd49c3..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/PasswordViewHolder.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-/*
- * Copyright © 2014-2021 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
deleted file mode 100644
index 758a927d..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt
+++ /dev/null
@@ -1,290 +0,0 @@
-/*
- * Copyright © 2014-2021 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.view.WindowManager
-import androidx.annotation.CallSuper
-import androidx.annotation.StringRes
-import androidx.appcompat.app.AppCompatActivity
-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 dagger.hilt.android.AndroidEntryPoint
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.injection.prefs.SettingsPreferences
-import dev.msfjarvis.aps.util.extensions.OPENPGP_PROVIDER
-import dev.msfjarvis.aps.util.extensions.asLog
-import dev.msfjarvis.aps.util.extensions.clipboard
-import dev.msfjarvis.aps.util.extensions.getString
-import dev.msfjarvis.aps.util.extensions.snackbar
-import dev.msfjarvis.aps.util.extensions.unsafeLazy
-import dev.msfjarvis.aps.util.features.Feature
-import dev.msfjarvis.aps.util.features.Features
-import dev.msfjarvis.aps.util.services.ClipboardService
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import java.io.File
-import javax.inject.Inject
-import logcat.LogPriority.ERROR
-import logcat.LogPriority.INFO
-import logcat.logcat
-import me.msfjarvis.openpgpktx.util.OpenPgpApi
-import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
-import org.openintents.openpgp.IOpenPgpService2
-import org.openintents.openpgp.OpenPgpError
-
-@Suppress("Registered")
-@AndroidEntryPoint
-open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
-
- /** Full path to the repository */
- val repoPath by unsafeLazy { intent.getStringExtra("REPO_PATH")!! }
-
- /** Full path to the password file being worked on */
- val fullPath by unsafeLazy { 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 unsafeLazy { File(fullPath).nameWithoutExtension }
-
- /** [SharedPreferences] instance used by subclasses to persist settings */
- @SettingsPreferences @Inject lateinit var settings: SharedPreferences
-
- @Inject lateinit var features: Features
-
- /**
- * 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)
- }
-
- /**
- * [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) {
- logcat(ERROR) { e.asLog("Callers must handle their own exceptions") }
- throw e
- }
-
- /** Method for subclasses to initiate binding with [OpenPgpServiceConnection]. */
- fun bindToOpenKeychain(onBoundListener: OpenPgpServiceConnection.OnBound) {
- if (features.isEnabled(Feature.EnablePGPainlessBackend)) return
- 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 {
- logcat(INFO) { "RESULT_CODE_USER_INTERACTION_REQUIRED" }
- return result.getParcelableExtra<PendingIntent>(OpenPgpApi.RESULT_INTENT)!!.intentSender
- }
-
- /**
- * 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))
- logcat(ERROR) { "onError getErrorId: ${error.errorId}" }
- logcat(ERROR) { "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 >= 26) {
- 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 EXTRA_FILE_PATH = "FILE_PATH"
- const val EXTRA_REPO_PATH = "REPO_PATH"
-
- /** 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
deleted file mode 100644
index af9a4ddd..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt
+++ /dev/null
@@ -1,227 +0,0 @@
-/*
- * Copyright © 2014-2021 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 android.view.Menu
-import android.view.MenuItem
-import androidx.activity.result.IntentSenderRequest
-import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult
-import androidx.lifecycle.lifecycleScope
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import dagger.hilt.android.AndroidEntryPoint
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.data.passfile.PasswordEntry
-import dev.msfjarvis.aps.data.password.FieldItem
-import dev.msfjarvis.aps.databinding.DecryptLayoutBinding
-import dev.msfjarvis.aps.ui.adapters.FieldItemAdapter
-import dev.msfjarvis.aps.util.extensions.unsafeLazy
-import dev.msfjarvis.aps.util.extensions.viewBinding
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import java.io.ByteArrayOutputStream
-import java.io.File
-import javax.inject.Inject
-import kotlin.time.Duration
-import kotlin.time.ExperimentalTime
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
-import me.msfjarvis.openpgpktx.util.OpenPgpApi
-import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
-import org.openintents.openpgp.IOpenPgpService2
-
-@AndroidEntryPoint
-class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
-
- private val binding by viewBinding(DecryptLayoutBinding::inflate)
- @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
-
- private val relativeParentPath by unsafeLazy { 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)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- bindToOpenKeychain(this)
- title = name
- with(binding) {
- setContentView(root)
- passwordCategory.text = relativeParentPath
- passwordFile.text = name
- passwordFile.setOnLongClickListener {
- copyTextToClipboard(name)
- true
- }
- }
- }
-
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.pgp_handler, menu)
- passwordEntry?.let { entry ->
- menu.findItem(R.id.edit_password).isVisible = true
- if (!entry.password.isNullOrBlank()) {
- 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)
- else -> return super.onOptionsItemSelected(item)
- }
- return true
- }
-
- override fun onBound(service: IOpenPgpService2) {
- super.onBound(service)
- decryptAndVerify()
- }
-
- override fun onError(e: Exception) {
- logcat(ERROR) { e.asLog() }
- }
-
- /**
- * 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(Duration.seconds(60))
- 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?.extraContentString)
- 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.Main) {
- val result =
- withContext(Dispatchers.IO) {
- checkNotNull(api).executeApi(data, inputStream, outputStream)
- }
- 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 entry = passwordEntryFactory.create(outputStream.toByteArray())
-
- if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) {
- copyPasswordToClipboard(entry.password)
- }
-
- passwordEntry = entry
- invalidateOptionsMenu()
-
- val items = arrayListOf<FieldItem>()
- if (!entry.password.isNullOrBlank()) {
- items.add(FieldItem.createPasswordField(entry.password!!))
- }
-
- if (entry.hasTotp()) {
- items.add(FieldItem.createOtpField(entry.totp.first()))
- }
-
- if (!entry.username.isNullOrBlank()) {
- items.add(FieldItem.createUsernameField(entry.username!!))
- }
-
- entry.extraContent.forEach { (key, value) ->
- items.add(FieldItem(key, value, FieldItem.ActionType.COPY))
- }
-
- val adapter =
- FieldItemAdapter(items, showPassword) { text -> copyTextToClipboard(text) }
- binding.recyclerView.adapter = adapter
- binding.recyclerView.itemAnimator = null
-
- if (entry.hasTotp()) {
- entry.totp.onEach(adapter::updateOTPCode).launchIn(lifecycleScope)
- }
- }
- .onFailure { e -> logcat(ERROR) { e.asLog() } }
- }
- 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/DecryptActivityV2.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivityV2.kt
deleted file mode 100644
index 424d5c46..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivityV2.kt
+++ /dev/null
@@ -1,209 +0,0 @@
-/*
- * Copyright © 2014-2021 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 android.view.Menu
-import android.view.MenuItem
-import androidx.lifecycle.lifecycleScope
-import com.github.michaelbull.result.runCatching
-import dagger.hilt.android.AndroidEntryPoint
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.data.crypto.CryptoRepository
-import dev.msfjarvis.aps.data.passfile.PasswordEntry
-import dev.msfjarvis.aps.data.password.FieldItem
-import dev.msfjarvis.aps.databinding.DecryptLayoutBinding
-import dev.msfjarvis.aps.ui.adapters.FieldItemAdapter
-import dev.msfjarvis.aps.util.extensions.isErr
-import dev.msfjarvis.aps.util.extensions.unsafeLazy
-import dev.msfjarvis.aps.util.extensions.viewBinding
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import java.io.ByteArrayOutputStream
-import java.io.File
-import javax.inject.Inject
-import kotlin.time.Duration.Companion.seconds
-import kotlin.time.ExperimentalTime
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-
-@OptIn(ExperimentalTime::class)
-@AndroidEntryPoint
-class DecryptActivityV2 : BasePgpActivity() {
-
- private val binding by viewBinding(DecryptLayoutBinding::inflate)
- @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
- @Inject lateinit var repository: CryptoRepository
- private val relativeParentPath by unsafeLazy { getParentPath(fullPath, repoPath) }
-
- private var passwordEntry: PasswordEntry? = null
- private var retries = 0
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- title = name
- with(binding) {
- setContentView(root)
- passwordCategory.text = relativeParentPath
- passwordFile.text = name
- passwordFile.setOnLongClickListener {
- copyTextToClipboard(name)
- true
- }
- }
- decrypt(isError = false)
- }
-
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.pgp_handler, menu)
- passwordEntry?.let { entry ->
- menu.findItem(R.id.edit_password).isVisible = true
- if (!entry.password.isNullOrBlank()) {
- 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)
- else -> return super.onOptionsItemSelected(item)
- }
- return true
- }
-
- /**
- * Automatically finishes the activity 60 seconds after decryption succeeded to prevent
- * information leaks from stale activities.
- */
- 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, PasswordCreationActivityV2::class.java)
- intent.putExtra("FILE_PATH", relativeParentPath)
- intent.putExtra("REPO_PATH", repoPath)
- intent.putExtra(PasswordCreationActivityV2.EXTRA_FILE_NAME, name)
- intent.putExtra(PasswordCreationActivityV2.EXTRA_PASSWORD, passwordEntry?.password)
- intent.putExtra(
- PasswordCreationActivityV2.EXTRA_EXTRA_CONTENT,
- passwordEntry?.extraContentString
- )
- intent.putExtra(PasswordCreationActivityV2.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))
- )
- }
-
- private fun decrypt(isError: Boolean) {
- if (retries < MAX_RETRIES) {
- retries += 1
- } else {
- finish()
- }
- val dialog = PasswordDialog()
- if (isError) {
- dialog.setError()
- }
- lifecycleScope.launch(Dispatchers.Main) {
- dialog.password.collectLatest { value ->
- if (value != null) {
- if (runCatching { decrypt(value) }.isErr()) {
- decrypt(isError = true)
- }
- }
- }
- }
- dialog.show(supportFragmentManager, "PASSWORD_DIALOG")
- }
-
- private suspend fun decrypt(password: String) {
- val message = withContext(Dispatchers.IO) { File(fullPath).readBytes().inputStream() }
- val result =
- withContext(Dispatchers.IO) {
- val outputStream = ByteArrayOutputStream()
- repository.decrypt(
- password,
- message,
- outputStream,
- )
- outputStream
- }
- require(result.size() != 0) { "Incorrect password" }
- startAutoDismissTimer()
-
- val entry = passwordEntryFactory.create(result.toByteArray())
- passwordEntry = entry
- createPasswordUi(entry)
- }
-
- private suspend fun createPasswordUi(entry: PasswordEntry) =
- withContext(Dispatchers.Main) {
- val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
- invalidateOptionsMenu()
-
- val items = arrayListOf<FieldItem>()
- if (!entry.password.isNullOrBlank()) {
- items.add(FieldItem.createPasswordField(entry.password!!))
- }
-
- if (entry.hasTotp()) {
- items.add(FieldItem.createOtpField(entry.totp.first()))
- }
-
- if (!entry.username.isNullOrBlank()) {
- items.add(FieldItem.createUsernameField(entry.username!!))
- }
-
- entry.extraContent.forEach { (key, value) ->
- items.add(FieldItem(key, value, FieldItem.ActionType.COPY))
- }
-
- val adapter = FieldItemAdapter(items, showPassword) { text -> copyTextToClipboard(text) }
- binding.recyclerView.adapter = adapter
- binding.recyclerView.itemAnimator = null
-
- if (entry.hasTotp()) {
- entry.totp.onEach(adapter::updateOTPCode).launchIn(lifecycleScope)
- }
- }
-
- private companion object {
- private const val MAX_RETRIES = 3
- }
-}
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
deleted file mode 100644
index afd30270..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- * Copyright © 2014-2021 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.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
-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) {
- logcat(ERROR) { e.asLog() }
- }
-
- /** Get the Key ids from OpenKeychain */
- private fun getKeyIds(data: Intent = Intent()) {
- data.action = OpenPgpApi.ACTION_GET_KEY_IDS
- lifecycleScope.launch(Dispatchers.Main) {
- val result = withContext(Dispatchers.IO) { checkNotNull(api).executeApi(data, null, null) }
- 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 -> logcat(ERROR) { e.asLog() } }
- }
- 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
deleted file mode 100644
index b1f11dfa..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt
+++ /dev/null
@@ -1,617 +0,0 @@
-/*
- * Copyright © 2014-2021 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.content.pm.PackageManager
-import android.graphics.Bitmap
-import android.graphics.ImageDecoder
-import android.os.Build
-import android.os.Bundle
-import android.provider.MediaStore
-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
-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.doAfterTextChanged
-import androidx.lifecycle.lifecycleScope
-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.android.material.snackbar.Snackbar
-import com.google.zxing.BinaryBitmap
-import com.google.zxing.LuminanceSource
-import com.google.zxing.RGBLuminanceSource
-import com.google.zxing.common.HybridBinarizer
-import com.google.zxing.integration.android.IntentIntegrator
-import com.google.zxing.integration.android.IntentIntegrator.QR_CODE
-import com.google.zxing.qrcode.QRCodeReader
-import dagger.hilt.android.AndroidEntryPoint
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.data.passfile.PasswordEntry
-import dev.msfjarvis.aps.data.repo.PasswordRepository
-import dev.msfjarvis.aps.databinding.PasswordCreationActivityBinding
-import dev.msfjarvis.aps.ui.dialogs.DicewarePasswordGeneratorDialogFragment
-import dev.msfjarvis.aps.ui.dialogs.OtpImportDialogFragment
-import dev.msfjarvis.aps.ui.dialogs.PasswordGeneratorDialogFragment
-import dev.msfjarvis.aps.util.autofill.AutofillPreferences
-import dev.msfjarvis.aps.util.autofill.DirectoryStructure
-import dev.msfjarvis.aps.util.crypto.GpgIdentifier
-import dev.msfjarvis.aps.util.extensions.asLog
-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.unsafeLazy
-import dev.msfjarvis.aps.util.extensions.viewBinding
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import java.io.ByteArrayInputStream
-import java.io.ByteArrayOutputStream
-import java.io.File
-import java.io.IOException
-import javax.inject.Inject
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
-import me.msfjarvis.openpgpktx.util.OpenPgpApi
-import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
-
-@AndroidEntryPoint
-class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
-
- private val binding by viewBinding(PasswordCreationActivityBinding::inflate)
- @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
-
- private val suggestedName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) }
- private val suggestedPass by unsafeLazy { intent.getStringExtra(EXTRA_PASSWORD) }
- private val suggestedExtra by unsafeLazy { intent.getStringExtra(EXTRA_EXTRA_CONTENT) }
- private val shouldGeneratePassword by unsafeLazy {
- intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
- }
- private val editing by unsafeLazy { intent.getBooleanExtra(EXTRA_EDITING, false) }
- private val oldFileName by unsafeLazy { 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 imageImportAction =
- registerForActivityResult(ActivityResultContracts.GetContent()) { imageUri ->
- if (imageUri == null) {
- snackbar(message = getString(R.string.otp_import_failure))
- return@registerForActivityResult
- }
- val bitmap =
- if (Build.VERSION.SDK_INT >= 28) {
- ImageDecoder.decodeBitmap(ImageDecoder.createSource(contentResolver, imageUri))
- .copy(Bitmap.Config.ARGB_8888, true)
- } else {
- @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(contentResolver, imageUri)
- }
- val intArray = IntArray(bitmap.width * bitmap.height)
- // copy pixel data from the Bitmap into the 'intArray' array
- bitmap.getPixels(intArray, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
- val source: LuminanceSource = RGBLuminanceSource(bitmap.width, bitmap.height, intArray)
- val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
-
- val reader = QRCodeReader()
- runCatching {
- val result = reader.decode(binaryBitmap)
- val text = result.text
- val currentExtras = binding.extraContent.text.toString()
- if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
- binding.extraContent.append("\n$text")
- else binding.extraContent.append(text)
- snackbar(message = getString(R.string.otp_import_success))
- binding.otpImportButton.isVisible = false
- }
- .onFailure { 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) }
- }
- }
- } else {
- snackbar(
- message = getString(R.string.gpg_key_select_mandatory),
- length = Snackbar.LENGTH_LONG
- )
- }
- }
-
- 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)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- 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 {
- supportFragmentManager.setFragmentResultListener(
- OTP_RESULT_REQUEST_KEY,
- this@PasswordCreationActivity
- ) { requestKey, bundle ->
- if (requestKey == OTP_RESULT_REQUEST_KEY) {
- val contents = bundle.getString(RESULT)
- val currentExtras = binding.extraContent.text.toString()
- if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
- binding.extraContent.append("\n$contents")
- else binding.extraContent.append(contents)
- }
- }
- val hasCamera = packageManager?.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) == true
- if (hasCamera) {
- val items =
- arrayOf(
- getString(R.string.otp_import_qr_code),
- getString(R.string.otp_import_from_file),
- getString(R.string.otp_import_manual_entry),
- )
- MaterialAlertDialogBuilder(this@PasswordCreationActivity)
- .setItems(items) { _, index ->
- when (index) {
- 0 ->
- otpImportAction.launch(
- IntentIntegrator(this@PasswordCreationActivity)
- .setOrientationLocked(false)
- .setBeepEnabled(false)
- .setDesiredBarcodeFormats(QR_CODE)
- .createScanIntent()
- )
- 1 -> imageImportAction.launch("image/*")
- 2 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport")
- }
- }
- .show()
- } else {
- OtpImportDialogFragment().show(supportFragmentManager, "OtpImport")
- }
- }
-
- 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 =
- passwordEntryFactory.create("PASSWORD\n${extraContent.text}".encodeToByteArray())
- 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)
- }
- }
- }
- }
- }
- 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
- }
- }
- listOf(binding.filename, binding.extraContent).forEach {
- it.doAfterTextChanged { updateViewState() }
- }
- 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)
- onBackPressed()
- }
- 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() {
- supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) {
- requestKey,
- bundle ->
- if (requestKey == PASSWORD_RESULT_REQUEST_KEY) {
- binding.password.setText(bundle.getString(RESULT))
- }
- }
- when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_CLASSIC) {
- KEY_PWGEN_TYPE_CLASSIC ->
- PasswordGeneratorDialogFragment().show(supportFragmentManager, "generator")
- KEY_PWGEN_TYPE_DICEWARE ->
- DicewarePasswordGeneratorDialogFragment().show(supportFragmentManager, "generator")
- }
- }
-
- private fun updateViewState() =
- with(binding) {
- // Use PasswordEntry to parse extras for username
- val entry =
- passwordEntryFactory.create("PLACEHOLDER\n${extraContent.text}".encodeToByteArray())
- encryptUsername.apply {
- if (visibility != View.VISIBLE) return@apply
- val hasUsernameInFileName = filename.text.toString().isNotBlank()
- val hasUsernameInExtras = !entry.username.isNullOrBlank()
- isEnabled = hasUsernameInFileName xor hasUsernameInExtras
- isChecked = hasUsernameInExtras
- }
- otpImportButton.isVisible = !entry.hasTotp()
- }
-
- /** 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)
- ?: File(repoRoot, ".gpg-id").apply { createNewFile() }
- val gpgIdentifiers =
- gpgIdentifierFile
- .readLines()
- .filter { it.isNotBlank() }
- .map { line ->
- GpgIdentifier.fromString(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, false)
-
- 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.Main) {
- val result =
- withContext(Dispatchers.IO) {
- checkNotNull(api).executeApi(encryptionIntent, inputStream, outputStream)
- }
- 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!
- // Additionally, if we were editing and the incoming and outgoing
- // filenames differ, it means we renamed. Ensure that the target
- // doesn't already exist to prevent an accidental overwrite.
- if (
- (!editing || (editing && suggestedName != file.nameWithoutExtension)) &&
- file.exists()
- ) {
- snackbar(message = getString(R.string.password_creation_duplicate_error))
- return@runCatching
- }
-
- if (!file.isInsideRepository()) {
- snackbar(message = getString(R.string.message_error_destination_outside_repo))
- return@runCatching
- }
-
- withContext(Dispatchers.IO) {
- 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 = passwordEntryFactory.create(content.encodeToByteArray())
- 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@runCatching
- }
- }
-
- 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) {
- logcat(ERROR) { e.asLog("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 {
- logcat(ERROR) { e.asLog() }
- }
- }
- }
- 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_DICEWARE = "diceware"
- const val PASSWORD_RESULT_REQUEST_KEY = "PASSWORD_GENERATOR"
- const val OTP_RESULT_REQUEST_KEY = "OTP_IMPORT"
- const val RESULT = "RESULT"
- 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/crypto/PasswordCreationActivityV2.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivityV2.kt
deleted file mode 100644
index 5792c892..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivityV2.kt
+++ /dev/null
@@ -1,479 +0,0 @@
-/*
- * Copyright © 2014-2021 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.content.pm.PackageManager
-import android.graphics.Bitmap
-import android.graphics.ImageDecoder
-import android.os.Build
-import android.os.Bundle
-import android.provider.MediaStore
-import android.text.InputType
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
-import androidx.core.content.edit
-import androidx.core.view.isVisible
-import androidx.core.widget.doAfterTextChanged
-import androidx.lifecycle.lifecycleScope
-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.BinaryBitmap
-import com.google.zxing.LuminanceSource
-import com.google.zxing.RGBLuminanceSource
-import com.google.zxing.common.HybridBinarizer
-import com.google.zxing.integration.android.IntentIntegrator
-import com.google.zxing.integration.android.IntentIntegrator.QR_CODE
-import com.google.zxing.qrcode.QRCodeReader
-import dagger.hilt.android.AndroidEntryPoint
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.data.crypto.CryptoRepository
-import dev.msfjarvis.aps.data.passfile.PasswordEntry
-import dev.msfjarvis.aps.databinding.PasswordCreationActivityBinding
-import dev.msfjarvis.aps.ui.dialogs.DicewarePasswordGeneratorDialogFragment
-import dev.msfjarvis.aps.ui.dialogs.OtpImportDialogFragment
-import dev.msfjarvis.aps.ui.dialogs.PasswordGeneratorDialogFragment
-import dev.msfjarvis.aps.util.autofill.AutofillPreferences
-import dev.msfjarvis.aps.util.autofill.DirectoryStructure
-import dev.msfjarvis.aps.util.extensions.asLog
-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.unsafeLazy
-import dev.msfjarvis.aps.util.extensions.viewBinding
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import java.io.ByteArrayOutputStream
-import java.io.File
-import java.io.IOException
-import javax.inject.Inject
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
-
-@AndroidEntryPoint
-class PasswordCreationActivityV2 : BasePgpActivity() {
-
- private val binding by viewBinding(PasswordCreationActivityBinding::inflate)
- @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
- @Inject lateinit var repository: CryptoRepository
-
- private val suggestedName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) }
- private val suggestedPass by unsafeLazy { intent.getStringExtra(EXTRA_PASSWORD) }
- private val suggestedExtra by unsafeLazy { intent.getStringExtra(EXTRA_EXTRA_CONTENT) }
- private val shouldGeneratePassword by unsafeLazy {
- intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
- }
- private val editing by unsafeLazy { intent.getBooleanExtra(EXTRA_EDITING, false) }
- private val oldFileName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) }
- private var oldCategory: String? = null
- private var copy: Boolean = false
-
- 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 imageImportAction =
- registerForActivityResult(ActivityResultContracts.GetContent()) { imageUri ->
- if (imageUri == null) {
- snackbar(message = getString(R.string.otp_import_failure))
- return@registerForActivityResult
- }
- val bitmap =
- if (Build.VERSION.SDK_INT >= 28) {
- ImageDecoder.decodeBitmap(ImageDecoder.createSource(contentResolver, imageUri))
- .copy(Bitmap.Config.ARGB_8888, true)
- } else {
- @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(contentResolver, imageUri)
- }
- val intArray = IntArray(bitmap.width * bitmap.height)
- // copy pixel data from the Bitmap into the 'intArray' array
- bitmap.getPixels(intArray, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
- val source: LuminanceSource = RGBLuminanceSource(bitmap.width, bitmap.height, intArray)
- val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
-
- val reader = QRCodeReader()
- runCatching {
- val result = reader.decode(binaryBitmap)
- val text = result.text
- val currentExtras = binding.extraContent.text.toString()
- if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
- binding.extraContent.append("\n$text")
- else binding.extraContent.append(text)
- snackbar(message = getString(R.string.otp_import_success))
- binding.otpImportButton.isVisible = false
- }
- .onFailure { snackbar(message = getString(R.string.otp_import_failure)) }
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- 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 {
- supportFragmentManager.setFragmentResultListener(
- OTP_RESULT_REQUEST_KEY,
- this@PasswordCreationActivityV2
- ) { requestKey, bundle ->
- if (requestKey == OTP_RESULT_REQUEST_KEY) {
- val contents = bundle.getString(RESULT)
- val currentExtras = binding.extraContent.text.toString()
- if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
- binding.extraContent.append("\n$contents")
- else binding.extraContent.append(contents)
- }
- }
- val hasCamera = packageManager?.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) == true
- if (hasCamera) {
- val items =
- arrayOf(
- getString(R.string.otp_import_qr_code),
- getString(R.string.otp_import_from_file),
- getString(R.string.otp_import_manual_entry),
- )
- MaterialAlertDialogBuilder(this@PasswordCreationActivityV2)
- .setItems(items) { _, index ->
- when (index) {
- 0 ->
- otpImportAction.launch(
- IntentIntegrator(this@PasswordCreationActivityV2)
- .setOrientationLocked(false)
- .setBeepEnabled(false)
- .setDesiredBarcodeFormats(QR_CODE)
- .createScanIntent()
- )
- 1 -> imageImportAction.launch("image/*")
- 2 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport")
- }
- }
- .show()
- } else {
- OtpImportDialogFragment().show(supportFragmentManager, "OtpImport")
- }
- }
-
- 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@PasswordCreationActivityV2) ==
- 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 =
- passwordEntryFactory.create("PASSWORD\n${extraContent.text}".encodeToByteArray())
- 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)
- }
- }
- }
- }
- }
- 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
- }
- }
- listOf(binding.filename, binding.extraContent).forEach {
- it.doAfterTextChanged { updateViewState() }
- }
- 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)
- onBackPressed()
- }
- 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() {
- supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) {
- requestKey,
- bundle ->
- if (requestKey == PASSWORD_RESULT_REQUEST_KEY) {
- binding.password.setText(bundle.getString(RESULT))
- }
- }
- when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_CLASSIC) {
- KEY_PWGEN_TYPE_CLASSIC ->
- PasswordGeneratorDialogFragment().show(supportFragmentManager, "generator")
- KEY_PWGEN_TYPE_DICEWARE ->
- DicewarePasswordGeneratorDialogFragment().show(supportFragmentManager, "generator")
- }
- }
-
- private fun updateViewState() =
- with(binding) {
- // Use PasswordEntry to parse extras for username
- val entry =
- passwordEntryFactory.create("PLACEHOLDER\n${extraContent.text}".encodeToByteArray())
- encryptUsername.apply {
- if (visibility != View.VISIBLE) return@apply
- val hasUsernameInFileName = filename.text.toString().isNotBlank()
- val hasUsernameInExtras = !entry.username.isNullOrBlank()
- isEnabled = hasUsernameInFileName xor hasUsernameInExtras
- isChecked = hasUsernameInExtras
- }
- otpImportButton.isVisible = !entry.hasTotp()
- }
-
- /** Encrypts the password and the extra content */
- private fun encrypt() {
- 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)
- }
-
- val content = "$editPass\n$editExtra"
- 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.Main) {
- runCatching {
- val result =
- withContext(Dispatchers.IO) {
- val outputStream = ByteArrayOutputStream()
- repository.encrypt(content.byteInputStream(), outputStream)
- outputStream
- }
- val file = File(path)
- // If we're not editing, this file should not already exist!
- // Additionally, if we were editing and the incoming and outgoing
- // filenames differ, it means we renamed. Ensure that the target
- // doesn't already exist to prevent an accidental overwrite.
- if (
- (!editing || (editing && suggestedName != file.nameWithoutExtension)) && file.exists()
- ) {
- snackbar(message = getString(R.string.password_creation_duplicate_error))
- return@runCatching
- }
-
- if (!file.isInsideRepository()) {
- snackbar(message = getString(R.string.message_error_destination_outside_repo))
- return@runCatching
- }
-
- withContext(Dispatchers.IO) { file.writeBytes(result.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 = passwordEntryFactory.create(content.encodeToByteArray())
- 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@PasswordCreationActivityV2)
- .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@runCatching
- }
- }
-
- 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) {
- logcat(ERROR) { e.asLog("Failed to write password file") }
- setResult(RESULT_CANCELED)
- MaterialAlertDialogBuilder(this@PasswordCreationActivityV2)
- .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 {
- logcat(ERROR) { e.asLog() }
- }
- }
- }
- }
- }
-
- companion object {
-
- private const val KEY_PWGEN_TYPE_CLASSIC = "classic"
- private const val KEY_PWGEN_TYPE_DICEWARE = "diceware"
- const val PASSWORD_RESULT_REQUEST_KEY = "PASSWORD_GENERATOR"
- const val OTP_RESULT_REQUEST_KEY = "OTP_IMPORT"
- const val RESULT = "RESULT"
- 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/crypto/PasswordDialog.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordDialog.kt
deleted file mode 100644
index d69a4686..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordDialog.kt
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- * Copyright © 2014-2022 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.ui.crypto
-
-import android.app.Dialog
-import android.content.DialogInterface
-import android.os.Bundle
-import android.view.KeyEvent
-import android.view.WindowManager
-import androidx.core.widget.doOnTextChanged
-import androidx.fragment.app.DialogFragment
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.databinding.DialogPasswordEntryBinding
-import dev.msfjarvis.aps.util.extensions.finish
-import dev.msfjarvis.aps.util.extensions.unsafeLazy
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
-
-/** [DialogFragment] to request a password from the user and forward it along. */
-class PasswordDialog : DialogFragment() {
-
- private val binding by unsafeLazy { DialogPasswordEntryBinding.inflate(layoutInflater) }
- private var isError: Boolean = false
- private val _password = MutableStateFlow<String?>(null)
- val password = _password.asStateFlow()
-
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val builder = MaterialAlertDialogBuilder(requireContext())
- builder.setView(binding.root)
- builder.setTitle(R.string.password)
- builder.setPositiveButton(android.R.string.ok) { _, _ -> tryEmitPassword() }
- val dialog = builder.create()
- dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
- dialog.setOnShowListener {
- if (isError) {
- binding.passwordField.error = getString(R.string.git_operation_wrong_password)
- }
- binding.passwordEditText.doOnTextChanged { _, _, _, _ -> binding.passwordField.error = null }
- binding.passwordEditText.setOnKeyListener { _, keyCode, _ ->
- if (keyCode == KeyEvent.KEYCODE_ENTER) {
- tryEmitPassword()
- return@setOnKeyListener true
- }
- false
- }
- }
- return dialog
- }
-
- fun setError() {
- isError = true
- }
-
- override fun onCancel(dialog: DialogInterface) {
- super.onCancel(dialog)
- finish()
- }
-
- @Suppress("ControlFlowWithEmptyBody")
- private fun tryEmitPassword() {
- do {} while (!_password.tryEmit(binding.passwordEditText.text.toString()))
- dismissAllowingStateLoss()
- }
-}
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
deleted file mode 100644
index 53d9a201..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt
+++ /dev/null
@@ -1,166 +0,0 @@
-/*
- * Copyright © 2014-2021 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.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.viewBinding
-
-/**
- * [BottomSheetDialogFragment] that exposes a simple [androidx.appcompat.app.AlertDialog] like API
- * through [BasicBottomSheet.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()
- }
- }
- }
- }
- )
- }
-
- 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/DicewarePasswordGeneratorDialogFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt
deleted file mode 100644
index 22f991c5..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt
+++ /dev/null
@@ -1,90 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.ui.dialogs
-
-import android.app.AlertDialog
-import android.app.Dialog
-import android.content.SharedPreferences
-import android.graphics.Typeface
-import android.os.Bundle
-import androidx.core.content.edit
-import androidx.core.os.bundleOf
-import androidx.fragment.app.DialogFragment
-import androidx.fragment.app.setFragmentResult
-import androidx.lifecycle.lifecycleScope
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import dagger.hilt.android.AndroidEntryPoint
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.databinding.FragmentPwgenDicewareBinding
-import dev.msfjarvis.aps.injection.prefs.PasswordGeneratorPreferences
-import dev.msfjarvis.aps.passgen.diceware.DicewarePassphraseGenerator
-import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivity
-import dev.msfjarvis.aps.util.extensions.getString
-import dev.msfjarvis.aps.util.settings.PreferenceKeys.DICEWARE_LENGTH
-import dev.msfjarvis.aps.util.settings.PreferenceKeys.DICEWARE_SEPARATOR
-import javax.inject.Inject
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.merge
-import kotlinx.coroutines.flow.onEach
-import reactivecircus.flowbinding.android.widget.afterTextChanges
-
-@AndroidEntryPoint
-class DicewarePasswordGeneratorDialogFragment : DialogFragment() {
-
- @Inject lateinit var dicewareGenerator: DicewarePassphraseGenerator
- @Inject @PasswordGeneratorPreferences lateinit var prefs: SharedPreferences
-
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val builder = MaterialAlertDialogBuilder(requireContext())
-
- val binding = FragmentPwgenDicewareBinding.inflate(layoutInflater)
- builder.setView(binding.root)
-
- binding.passwordSeparatorText.setText(prefs.getString(DICEWARE_SEPARATOR) ?: "-")
- binding.passwordLengthText.setText(prefs.getInt(DICEWARE_LENGTH, 5).toString())
- binding.passwordText.typeface = Typeface.MONOSPACE
-
- merge(
- binding.passwordLengthText.afterTextChanges(),
- binding.passwordSeparatorText.afterTextChanges(),
- )
- .onEach { generatePassword(binding) }
- .launchIn(lifecycleScope)
- return builder
- .run {
- setTitle(R.string.pwgen_title)
- setPositiveButton(R.string.dialog_ok) { _, _ ->
- setFragmentResult(
- PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
- bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}")
- )
- }
- setNeutralButton(R.string.dialog_cancel) { _, _ -> }
- setNegativeButton(R.string.pwgen_generate, null)
- create()
- }
- .apply {
- setOnShowListener {
- generatePassword(binding)
- getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { generatePassword(binding) }
- }
- }
- }
-
- private fun generatePassword(binding: FragmentPwgenDicewareBinding) {
- val length = binding.passwordLengthText.text?.toString()?.toIntOrNull() ?: 5
- val separator = binding.passwordSeparatorText.text?.toString()?.getOrNull(0) ?: '-'
- setPreferences(length, separator)
- binding.passwordText.text = dicewareGenerator.generatePassphrase(length, separator)
- }
-
- private fun setPreferences(length: Int, separator: Char) {
- prefs.edit {
- putInt(DICEWARE_LENGTH, length)
- putString(DICEWARE_SEPARATOR, separator.toString())
- }
- }
-}
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
deleted file mode 100644
index 9b7b3e21..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/FolderCreationDialogFragment.kt
+++ /dev/null
@@ -1,110 +0,0 @@
-/*
- * Copyright © 2014-2021 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 android.view.WindowManager
-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.R
-import dev.msfjarvis.aps.data.repo.PasswordRepository
-import dev.msfjarvis.aps.ui.crypto.BasePgpActivity
-import dev.msfjarvis.aps.ui.crypto.GetKeyIdsActivity
-import dev.msfjarvis.aps.ui.passwords.PasswordStore
-import dev.msfjarvis.aps.util.extensions.commitChange
-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"))
- if (PasswordRepository.repository != 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.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
- 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
deleted file mode 100644
index af2a5f19..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/ItemCreationBottomSheet.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.ui.dialogs
-
-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.R
-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
-
-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()
- }
- }
- }
- )
- }
-
- override fun dismiss() {
- super.dismiss()
- behavior?.removeBottomSheetCallback(bottomSheetCallback)
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/OtpImportDialogFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/OtpImportDialogFragment.kt
deleted file mode 100644
index 1670d263..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/OtpImportDialogFragment.kt
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- * Copyright © 2014-2021 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.net.Uri
-import android.os.Bundle
-import android.view.WindowManager
-import androidx.core.os.bundleOf
-import androidx.fragment.app.DialogFragment
-import androidx.fragment.app.setFragmentResult
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import dev.msfjarvis.aps.databinding.FragmentManualOtpEntryBinding
-import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivity
-
-class OtpImportDialogFragment : DialogFragment() {
-
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val builder = MaterialAlertDialogBuilder(requireContext())
- val binding = FragmentManualOtpEntryBinding.inflate(layoutInflater)
- builder.setView(binding.root)
- builder.setPositiveButton(android.R.string.ok) { _, _ ->
- setFragmentResult(
- PasswordCreationActivity.OTP_RESULT_REQUEST_KEY,
- bundleOf(PasswordCreationActivity.RESULT to getTOTPUri(binding))
- )
- }
- val dialog = builder.create()
- dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
- return dialog
- }
-
- private fun getTOTPUri(binding: FragmentManualOtpEntryBinding): String {
- val secret = binding.secret.text.toString()
- val account = binding.account.text.toString()
- if (secret.isBlank()) return ""
- val builder = Uri.Builder()
- builder.scheme("otpauth")
- builder.authority("totp")
- builder.appendQueryParameter("secret", secret)
- if (account.isNotBlank()) builder.appendQueryParameter("issuer", account)
- return builder.build().toString()
- }
-}
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
deleted file mode 100644
index e14076b1..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt
+++ /dev/null
@@ -1,149 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.ui.dialogs
-
-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.AppCompatTextView
-import androidx.core.content.edit
-import androidx.core.os.bundleOf
-import androidx.fragment.app.DialogFragment
-import androidx.fragment.app.setFragmentResult
-import androidx.lifecycle.lifecycleScope
-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.databinding.FragmentPwgenBinding
-import dev.msfjarvis.aps.passgen.random.MaxIterationsExceededException
-import dev.msfjarvis.aps.passgen.random.NoCharactersIncludedException
-import dev.msfjarvis.aps.passgen.random.PasswordGenerator
-import dev.msfjarvis.aps.passgen.random.PasswordLengthTooShortException
-import dev.msfjarvis.aps.passgen.random.PasswordOption
-import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivity
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.merge
-import kotlinx.coroutines.flow.onEach
-import reactivecircus.flowbinding.android.widget.afterTextChanges
-import reactivecircus.flowbinding.android.widget.checkedChanges
-
-class PasswordGeneratorDialogFragment : DialogFragment() {
-
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val prefs = requireContext().getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
- val builder = MaterialAlertDialogBuilder(requireContext())
-
- val binding = FragmentPwgenBinding.inflate(layoutInflater)
- builder.setView(binding.root)
-
- binding.numerals.isChecked = !prefs.getBoolean(PasswordOption.NoDigits.key, false)
- binding.symbols.isChecked = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false)
- binding.uppercase.isChecked = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false)
- binding.lowercase.isChecked = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false)
- binding.ambiguous.isChecked = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false)
- binding.pronounceable.isChecked = !prefs.getBoolean(PasswordOption.FullyRandom.key, true)
- binding.lengthNumber.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString())
- binding.passwordText.typeface = Typeface.MONOSPACE
-
- merge(
- binding.numerals.checkedChanges().skipInitialValue(),
- binding.symbols.checkedChanges().skipInitialValue(),
- binding.uppercase.checkedChanges().skipInitialValue(),
- binding.lowercase.checkedChanges().skipInitialValue(),
- binding.ambiguous.checkedChanges().skipInitialValue(),
- binding.pronounceable.checkedChanges().skipInitialValue(),
- binding.lengthNumber.afterTextChanges().skipInitialValue(),
- )
- .onEach { generate(binding.passwordText) }
- .launchIn(lifecycleScope)
-
- return builder
- .run {
- setTitle(R.string.pwgen_title)
- setPositiveButton(R.string.dialog_ok) { _, _ ->
- setFragmentResult(
- PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
- bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}")
- )
- }
- setNeutralButton(R.string.dialog_cancel) { _, _ -> }
- setNegativeButton(R.string.pwgen_generate, null)
- create()
- }
- .apply {
- setOnShowListener {
- generate(binding.passwordText)
- getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
- generate(binding.passwordText)
- }
- }
- }
- }
-
- private fun generate(passwordField: AppCompatTextView) {
- val passwordOptions = getSelectedOptions()
- val passwordLength = getLength()
- setPrefs(requireContext(), passwordOptions, passwordLength)
- passwordField.text =
- runCatching { PasswordGenerator.generate(passwordOptions, passwordLength) }
- .getOrElse { exception ->
- val errorText =
- when (exception) {
- is MaxIterationsExceededException ->
- requireContext().getString(R.string.pwgen_max_iterations_exceeded)
- is NoCharactersIncludedException ->
- requireContext().getString(R.string.pwgen_no_chars_error)
- is PasswordLengthTooShortException ->
- requireContext().getString(R.string.pwgen_length_too_short_error)
- else -> requireContext().getString(R.string.pwgen_some_error_occurred)
- }
- Toast.makeText(requireActivity(), errorText, Toast.LENGTH_SHORT).show()
- ""
- }
- }
-
- private fun isChecked(@IdRes id: Int): Boolean {
- return requireDialog().findViewById<CheckBox>(id).isChecked
- }
-
- private fun getSelectedOptions(): List<PasswordOption> {
- return 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) }
- )
- }
-
- private fun getLength(): Int {
- val lengthText = requireDialog().findViewById<EditText>(R.id.lengthNumber).text.toString()
- return lengthText.toIntOrNull()?.takeIf { it >= 0 } ?: PasswordGenerator.DEFAULT_LENGTH
- }
-
- /**
- * Enables the [PasswordOption]s in [options] and sets [targetLength] as the length for generated
- * passwords.
- */
- private 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
- }
-}
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
deleted file mode 100644
index 48ed5b79..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderActivity.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- * Copyright © 2014-2021 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)
- onBackPressed()
- }
- R.id.crypto_select -> selectFolder()
- else -> return super.onOptionsItemSelected(item)
- }
- return true
- }
-
- 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
deleted file mode 100644
index cae55ba1..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderFragment.kt
+++ /dev/null
@@ -1,86 +0,0 @@
-/*
- * Copyright © 2014-2021 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.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.LinearLayoutManager
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.data.password.PasswordItem
-import dev.msfjarvis.aps.databinding.PasswordRecyclerViewBinding
-import dev.msfjarvis.aps.ui.adapters.PasswordItemRecyclerAdapter
-import dev.msfjarvis.aps.ui.passwords.PasswordStore
-import dev.msfjarvis.aps.util.extensions.viewBinding
-import dev.msfjarvis.aps.util.viewmodel.ListMode
-import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryViewModel
-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(lifecycleScope).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
deleted file mode 100644
index 614d9a52..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/git/base/BaseGitActivity.kt
+++ /dev/null
@@ -1,185 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.ui.git.base
-
-import android.content.SharedPreferences
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.content.edit
-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 dagger.hilt.android.AndroidEntryPoint
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.injection.prefs.GitPreferences
-import dev.msfjarvis.aps.util.extensions.sharedPrefs
-import dev.msfjarvis.aps.util.git.ErrorMessages
-import dev.msfjarvis.aps.util.git.operation.BreakOutOfDetached
-import dev.msfjarvis.aps.util.git.operation.CloneOperation
-import dev.msfjarvis.aps.util.git.operation.GcOperation
-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.GitSettings
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import javax.inject.Inject
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import logcat.asLog
-import logcat.logcat
-import net.schmizz.sshj.common.DisconnectReason
-import net.schmizz.sshj.common.SSHException
-import net.schmizz.sshj.transport.TransportException
-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.
- */
-@AndroidEntryPoint
-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,
- GC,
- }
-
- @Inject lateinit var gitSettings: GitSettings
- @GitPreferences @Inject lateinit var gitPrefs: SharedPreferences
-
- /**
- * 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, gitSettings.rebaseOnPull)
- GitOp.PUSH -> PushOperation(this)
- GitOp.SYNC -> SyncOperation(this, gitSettings.rebaseOnPull)
- GitOp.BREAK_OUT_OF_DETACHED -> BreakOutOfDetached(this)
- GitOp.RESET -> ResetToRemoteOperation(this)
- GitOp.GC -> GcOperation(this)
- }
- return (if (op.requiresAuth) {
- op.executeAfterAuthentication(gitSettings.authMode)
- } else {
- op.execute()
- })
- .mapError(::transformGitError)
- }
-
- fun finishOnSuccessHandler(@Suppress("UNUSED_PARAMETER") nothing: Unit) {
- finish()
- }
-
- suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) {
- val error = rootCauseException(err)
- if (!isExplicitlyUserInitiatedError(error)) {
- gitPrefs.edit { remove(PreferenceKeys.HTTPS_PASSWORD) }
- sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) }
- logcat { error.asLog() }
- 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()
- }
- }
-
- /**
- * Takes the result of [launchGitOperation] and applies any necessary transformations on the
- * [throwable] returned from it
- */
- private fun transformGitError(throwable: Throwable): Throwable {
- val err = rootCauseException(throwable)
- return when {
- 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."
- )
- }
- err.message?.contains("int org.eclipse.jgit.lib.AnyObjectId.w1") == true -> {
- IllegalStateException(
- "Your local repository appears to be an incomplete Git clone, please delete and re-clone from settings"
- )
- }
- err is TransportException &&
- err.disconnectReason == DisconnectReason.HOST_KEY_NOT_VERIFIABLE -> {
- SSHException(
- DisconnectReason.HOST_KEY_NOT_VERIFIABLE,
- "WARNING: The remote host key has changed. If this is expected, please go to Git server settings and clear the saved host key."
- )
- }
- else -> {
- err
- }
- }
- }
-
- /**
- * 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
deleted file mode 100644
index 03c92313..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitConfigActivity.kt
+++ /dev/null
@@ -1,159 +0,0 @@
-/*
- * Copyright © 2014-2021 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.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.data.repo.PasswordRepository
-import dev.msfjarvis.aps.databinding.ActivityGitConfigBinding
-import dev.msfjarvis.aps.ui.git.base.BaseGitActivity
-import dev.msfjarvis.aps.ui.git.log.GitLogActivity
-import dev.msfjarvis.aps.util.extensions.viewBinding
-import kotlinx.coroutines.launch
-import logcat.LogPriority.ERROR
-import logcat.logcat
-import org.eclipse.jgit.lib.Constants
-import org.eclipse.jgit.lib.Repository
-import org.eclipse.jgit.lib.RepositoryState
-
-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 -> {
- onBackPressed()
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
- /** Sets up the UI components of the tools section. */
- private fun setupTools() {
- val repo = PasswordRepository.repository
- if (repo != null) {
- binding.gitHeadStatus.text = headStatusMsg(repo)
- // enable the abort button only if we're rebasing or merging
- val needsAbort =
- repo.repositoryState.isRebasing || repo.repositoryState == RepositoryState.MERGING
- binding.gitAbortRebase.isEnabled = needsAbort
- binding.gitAbortRebase.alpha = if (needsAbort) 1.0f else 0.5f
- }
- binding.gitLog.setOnClickListener {
- runCatching { startActivity(Intent(this, GitLogActivity::class.java)) }
- .onFailure { ex -> logcat(ERROR) { "Failed to start GitLogActivity\n${ex}" } }
- }
- 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() } },
- )
- }
- }
- binding.gitGc.setOnClickListener {
- lifecycleScope.launch {
- launchGitOperation(GitOp.GC)
- .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 ->
- logcat(ERROR) { "Error getting HEAD reference\n${ex}" }
- 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
deleted file mode 100644
index 93f104fb..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitServerConfigActivity.kt
+++ /dev/null
@@ -1,305 +0,0 @@
-/*
- * Copyright © 2014-2021 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.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.data.repo.PasswordRepository
-import dev.msfjarvis.aps.databinding.ActivityGitCloneBinding
-import dev.msfjarvis.aps.ui.dialogs.BasicBottomSheet
-import dev.msfjarvis.aps.ui.git.base.BaseGitActivity
-import dev.msfjarvis.aps.util.extensions.snackbar
-import dev.msfjarvis.aps.util.extensions.viewBinding
-import dev.msfjarvis.aps.util.settings.AuthMode
-import dev.msfjarvis.aps.util.settings.GitSettings
-import dev.msfjarvis.aps.util.settings.Protocol
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
-
-/**
- * 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 { _, checkedId, isChecked ->
- if (!isChecked) {
- newAuthMode = AuthMode.None
- return@addOnButtonCheckedListener
- }
- when (checkedId) {
- 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.clearHostKeyButton.isVisible = gitSettings.hasSavedHostKey()
- binding.clearHostKeyButton.setOnClickListener {
- gitSettings.clearSavedHostKey()
- Snackbar.make(
- binding.root,
- getString(R.string.clear_saved_host_key_success),
- Snackbar.LENGTH_LONG
- )
- .show()
- it.isVisible = false
- }
- 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
- }
- }
- if (newUrl.startsWith("git://")) {
- BasicBottomSheet.Builder(this)
- .setTitleRes(R.string.git_scheme_disallowed_title)
- .setMessageRes(R.string.git_scheme_disallowed_message)
- .setPositiveButtonClickListener {}
- .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.repository == 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 -> {
- onBackPressed()
- 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 ->
- logcat(ERROR) { e.asLog() }
- 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
deleted file mode 100644
index 4265717d..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright © 2014-2021 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
deleted file mode 100644
index a3080fef..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
- * Copyright © 2014-2021 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 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
-import logcat.LogPriority.ERROR
-import logcat.logcat
-
-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) {
- logcat(ERROR) { "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
deleted file mode 100644
index 17005b6a..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/main/LaunchActivity.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-/*
- * Copyright © 2014-2021 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 dagger.hilt.android.AndroidEntryPoint
-import dev.msfjarvis.aps.ui.crypto.BasePgpActivity
-import dev.msfjarvis.aps.ui.crypto.DecryptActivity
-import dev.msfjarvis.aps.ui.crypto.DecryptActivityV2
-import dev.msfjarvis.aps.ui.passwords.PasswordStore
-import dev.msfjarvis.aps.util.auth.BiometricAuthenticator
-import dev.msfjarvis.aps.util.auth.BiometricAuthenticator.Result
-import dev.msfjarvis.aps.util.extensions.sharedPrefs
-import dev.msfjarvis.aps.util.features.Feature
-import dev.msfjarvis.aps.util.features.Features
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import javax.inject.Inject
-
-@AndroidEntryPoint
-class LaunchActivity : AppCompatActivity() {
-
- @Inject lateinit var features: Features
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- val prefs = sharedPrefs
- if (prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH, false)) {
- BiometricAuthenticator.authenticate(this) { result ->
- when (result) {
- is Result.Success -> {
- startTargetActivity(false)
- }
- is Result.HardwareUnavailableOrDisabled -> {
- prefs.edit { remove(PreferenceKeys.BIOMETRIC_AUTH) }
- startTargetActivity(false)
- }
- is Result.Failure,
- Result.Cancelled -> {
- finish()
- }
- is Result.Retry -> {}
- }
- }
- } else {
- startTargetActivity(true)
- }
- }
-
- private fun getDecryptIntent(): Intent {
- return if (features.isEnabled(Feature.EnablePGPainlessBackend)) {
- Intent(this, DecryptActivityV2::class.java)
- } else {
- Intent(this, DecryptActivity::class.java)
- }
- }
-
- private fun startTargetActivity(noAuth: Boolean) {
- val intentToStart =
- if (intent.action == ACTION_DECRYPT_PASS)
- getDecryptIntent().apply {
- putExtra(
- BasePgpActivity.EXTRA_FILE_PATH,
- intent.getStringExtra(BasePgpActivity.EXTRA_FILE_PATH)
- )
- putExtra(
- BasePgpActivity.EXTRA_REPO_PATH,
- intent.getStringExtra(BasePgpActivity.EXTRA_REPO_PATH)
- )
- }
- 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
deleted file mode 100644
index 31ca362c..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/activity/OnboardingActivity.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- * Copyright © 2014-2021 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
deleted file mode 100644
index e3d1df85..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/CloneFragment.kt
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- * Copyright © 2014-2021 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 com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.data.repo.PasswordRepository
-import dev.msfjarvis.aps.databinding.FragmentCloneBinding
-import dev.msfjarvis.aps.ui.git.config.GitServerConfigActivity
-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.unsafeLazy
-import dev.msfjarvis.aps.util.extensions.viewBinding
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
-
-class CloneFragment : Fragment(R.layout.fragment_clone) {
-
- private val binding by viewBinding(FragmentCloneBinding::bind)
-
- private val settings by unsafeLazy { 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 { createRepository() }
- }
-
- /** Clones a remote Git repository to the app's private directory */
- private fun cloneToHiddenDir() {
- cloneAction.launch(GitServerConfigActivity.createCloneIntent(requireContext()))
- }
-
- 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 ->
- logcat(ERROR) { e.asLog() }
- if (!localDir.delete()) {
- logcat { "Failed to delete local repository: $localDir" }
- }
- finish()
- }
- }
-
- 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
deleted file mode 100644
index 7783e1eb..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/KeySelectionFragment.kt
+++ /dev/null
@@ -1,74 +0,0 @@
-/*
- * Copyright © 2014-2021 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 com.google.android.material.snackbar.Snackbar
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.data.repo.PasswordRepository
-import dev.msfjarvis.aps.databinding.FragmentKeySelectionBinding
-import dev.msfjarvis.aps.ui.crypto.GetKeyIdsActivity
-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.snackbar
-import dev.msfjarvis.aps.util.extensions.unsafeLazy
-import dev.msfjarvis.aps.util.extensions.viewBinding
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-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 unsafeLazy { 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)))
- }
- }
- finish()
- } else {
- requireActivity()
- .snackbar(
- message = getString(R.string.gpg_key_select_mandatory),
- length = Snackbar.LENGTH_LONG
- )
- }
- }
-
- 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/WelcomeFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/WelcomeFragment.kt
deleted file mode 100644
index ef87741f..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/WelcomeFragment.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
- * Copyright © 2014-2021 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.databinding.FragmentWelcomeBinding
-import dev.msfjarvis.aps.ui.settings.SettingsActivity
-import dev.msfjarvis.aps.util.extensions.performTransactionWithBackStack
-import dev.msfjarvis.aps.util.extensions.viewBinding
-
-@Keep
-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(), SettingsActivity::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
deleted file mode 100644
index c70f0b5a..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt
+++ /dev/null
@@ -1,382 +0,0 @@
-/*
- * Copyright © 2014-2021 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.onFailure
-import com.github.michaelbull.result.runCatching
-import dagger.hilt.android.AndroidEntryPoint
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.data.password.PasswordItem
-import dev.msfjarvis.aps.data.repo.PasswordRepository
-import dev.msfjarvis.aps.databinding.PasswordRecyclerViewBinding
-import dev.msfjarvis.aps.injection.prefs.SettingsPreferences
-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.ui.git.base.BaseGitActivity
-import dev.msfjarvis.aps.ui.git.config.GitServerConfigActivity
-import dev.msfjarvis.aps.ui.util.OnOffItemAnimator
-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 dev.msfjarvis.aps.util.settings.AuthMode
-import dev.msfjarvis.aps.util.settings.GitSettings
-import dev.msfjarvis.aps.util.settings.PasswordSortOrder
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import dev.msfjarvis.aps.util.shortcuts.ShortcutHandler
-import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryViewModel
-import java.io.File
-import javax.inject.Inject
-import kotlinx.coroutines.launch
-import me.zhanghai.android.fastscroll.FastScrollerBuilder
-
-@AndroidEntryPoint
-class PasswordFragment : Fragment(R.layout.password_recycler_view) {
-
- @Inject lateinit var gitSettings: GitSettings
- @Inject lateinit var shortcutHandler: ShortcutHandler
- @Inject @SettingsPreferences lateinit var prefs: SharedPreferences
- 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(lifecycleScope)
- .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 {
- val selectedItems = recyclerAdapter.getSelectedItems()
- menu.findItem(R.id.menu_edit_password).isVisible =
- selectedItems.all { it.type == PasswordItem.TYPE_CATEGORY }
- menu.findItem(R.id.menu_pin_password).isVisible =
- selectedItems.size == 1 && selectedItems[0].type == PasswordItem.TYPE_PASSWORD
- 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
- }
- R.id.menu_pin_password -> {
- val passwordItem = recyclerAdapter.getSelectedItems()[0]
- shortcutHandler.addPinnedShortcut(
- passwordItem,
- passwordItem.createAuthEnabledIntent(requireContext())
- )
- 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 onResume() {
- super.onResume()
- binding.swipeRefresher.isEnabled = !prefs.getBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, false)
- }
-
- 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
deleted file mode 100644
index 0ee42896..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt
+++ /dev/null
@@ -1,639 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.ui.passwords
-
-import android.annotation.SuppressLint
-import android.content.ComponentName
-import android.content.Context
-import android.content.Intent
-import android.os.Bundle
-import android.view.KeyEvent
-import android.view.Menu
-import android.view.MenuItem
-import android.view.MenuItem.OnActionExpandListener
-import android.view.WindowManager
-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.fragment.app.FragmentManager
-import androidx.fragment.app.commit
-import androidx.lifecycle.ViewModelProvider
-import androidx.lifecycle.lifecycleScope
-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.textfield.TextInputEditText
-import dagger.hilt.android.AndroidEntryPoint
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.data.password.PasswordItem
-import dev.msfjarvis.aps.data.repo.PasswordRepository
-import dev.msfjarvis.aps.ui.crypto.BasePgpActivity
-import dev.msfjarvis.aps.ui.crypto.BasePgpActivity.Companion.getLongName
-import dev.msfjarvis.aps.ui.crypto.DecryptActivity
-import dev.msfjarvis.aps.ui.crypto.DecryptActivityV2
-import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivity
-import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivityV2
-import dev.msfjarvis.aps.ui.dialogs.FolderCreationDialogFragment
-import dev.msfjarvis.aps.ui.folderselect.SelectFolderActivity
-import dev.msfjarvis.aps.ui.git.base.BaseGitActivity
-import dev.msfjarvis.aps.ui.onboarding.activity.OnboardingActivity
-import dev.msfjarvis.aps.ui.settings.SettingsActivity
-import dev.msfjarvis.aps.util.autofill.AutofillMatcher
-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.listFilesRecursively
-import dev.msfjarvis.aps.util.extensions.sharedPrefs
-import dev.msfjarvis.aps.util.features.Feature
-import dev.msfjarvis.aps.util.features.Features
-import dev.msfjarvis.aps.util.settings.AuthMode
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import dev.msfjarvis.aps.util.shortcuts.ShortcutHandler
-import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryViewModel
-import java.io.File
-import java.lang.Character.UnicodeBlock
-import javax.inject.Inject
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import logcat.LogPriority.ERROR
-import logcat.LogPriority.INFO
-import logcat.logcat
-
-const val PASSWORD_FRAGMENT_TAG = "PasswordsList"
-
-@AndroidEntryPoint
-class PasswordStore : BaseGitActivity() {
-
- @Inject lateinit var features: Features
- @Inject lateinit var shortcutHandler: ShortcutHandler
- 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) {
- logcat(ERROR) { "Tried moving passwords to a non-existing folder." }
- return@registerForActivityResult
- }
-
- logcat { "Moving passwords to ${intentData.getStringExtra("SELECTED_FOLDER_PATH")}" }
- logcat { filesToMove.joinToString(", ") }
-
- lifecycleScope.launch(Dispatchers.IO) {
- for (file in filesToMove) {
- val source = File(file)
- if (!source.exists()) {
- logcat(ERROR) { "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()) {
- logcat(ERROR) { "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?) {
- super.onCreate(savedInstanceState)
- 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()
- 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)
- }
-
- 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, SettingsActivity::class.java)) }
- .onFailure { e -> e.printStackTrace() }
- }
- R.id.git_push -> {
- if (!PasswordRepository.isInitialized) {
- initBefore.show()
- } else {
- runGitOperation(GitOp.PUSH)
- }
- }
- R.id.git_pull -> {
- if (!PasswordRepository.isInitialized) {
- initBefore.show()
- } else {
- runGitOperation(GitOp.PULL)
- }
- }
- R.id.git_sync -> {
- if (!PasswordRepository.isInitialized) {
- initBefore.show()
- } else {
- runGitOperation(GitOp.SYNC)
- }
- }
- R.id.refresh -> refreshPasswordList()
- android.R.id.home -> onBackPressed()
- else -> return super.onOptionsItemSelected(item)
- }
- return true
- }
-
- 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) },
- )
- }
-
- private fun checkLocalRepository() {
- PasswordRepository.initialize()
- checkLocalRepository(PasswordRepository.getRepositoryDirectory())
- }
-
- private fun checkLocalRepository(localDir: File?) {
- if (localDir != null && settings.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) {
- logcat { "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(), "/")
- }
-
- fun decryptPassword(item: PasswordItem) {
- val authDecryptIntent = item.createAuthEnabledIntent(this)
- val decryptIntent =
- (authDecryptIntent.clone() as Intent).setComponent(
- ComponentName(
- this,
- if (features.isEnabled(Feature.EnablePGPainlessBackend)) {
- DecryptActivityV2::class.java
- } else {
- DecryptActivity::class.java
- }
- )
- )
-
- startActivity(decryptIntent)
-
- // Adds shortcut
- shortcutHandler.addDynamicShortcut(item, authDecryptIntent)
- }
-
- 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
- logcat(INFO) { "Adding file to : ${currentDir.absolutePath}" }
- val creationActivity =
- if (features.isEnabled(Feature.EnablePGPainlessBackend))
- PasswordCreationActivityV2::class.java
- else PasswordCreationActivity::class.java
- val intent = Intent(this, creationActivity)
- intent.putExtra(BasePgpActivity.EXTRA_FILE_PATH, currentDir.absolutePath)
- intent.putExtra(
- BasePgpActivity.EXTRA_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.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
- 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)) {
- logcat(ERROR) { "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/pgp/PGPKeyImportActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/pgp/PGPKeyImportActivity.kt
deleted file mode 100644
index e140ac91..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/pgp/PGPKeyImportActivity.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-@file:Suppress("BlockingMethodInNonBlockingContext")
-
-package dev.msfjarvis.aps.ui.pgp
-
-import android.os.Bundle
-import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
-import androidx.appcompat.app.AppCompatActivity
-import com.github.michaelbull.result.mapBoth
-import com.github.michaelbull.result.runCatching
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import dagger.hilt.android.AndroidEntryPoint
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.crypto.KeyUtils.tryGetId
-import dev.msfjarvis.aps.crypto.PGPKey
-import dev.msfjarvis.aps.crypto.PGPKeyManager
-import javax.inject.Inject
-import kotlinx.coroutines.runBlocking
-
-@AndroidEntryPoint
-class PGPKeyImportActivity : AppCompatActivity() {
-
- @Inject lateinit var keyManager: PGPKeyManager
-
- private val pgpKeyImportAction =
- registerForActivityResult(OpenDocument()) { uri ->
- runCatching {
- if (uri == null) {
- return@runCatching null
- }
- val keyInputStream =
- contentResolver.openInputStream(uri)
- ?: throw IllegalStateException("Failed to open selected file")
- val bytes = keyInputStream.readBytes()
- val (key, error) = runBlocking { keyManager.addKey(PGPKey(bytes)) }
- if (error != null) throw error
- key
- }
- .mapBoth(
- { key ->
- if (key != null) {
- MaterialAlertDialogBuilder(this)
- .setTitle(getString(R.string.pgp_key_import_succeeded))
- .setMessage(getString(R.string.pgp_key_import_succeeded_message, tryGetId(key)))
- .setPositiveButton(android.R.string.ok) { _, _ -> finish() }
- .setOnCancelListener { finish() }
- .show()
- } else {
- finish()
- }
- },
- { throwable ->
- MaterialAlertDialogBuilder(this)
- .setTitle(getString(R.string.pgp_key_import_failed))
- .setMessage(throwable.message)
- .setPositiveButton(android.R.string.ok) { _, _ -> finish() }
- .setOnCancelListener { finish() }
- .show()
- }
- )
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- pgpKeyImportAction.launch(arrayOf("*/*"))
- }
-}
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
deleted file mode 100644
index 41cab040..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/proxy/ProxySelectorActivity.kt
+++ /dev/null
@@ -1,94 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.ui.proxy
-
-import android.content.SharedPreferences
-import android.net.InetAddresses
-import android.os.Build
-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 dagger.hilt.android.AndroidEntryPoint
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.databinding.ActivityProxySelectorBinding
-import dev.msfjarvis.aps.injection.prefs.ProxyPreferences
-import dev.msfjarvis.aps.util.extensions.getString
-import dev.msfjarvis.aps.util.extensions.viewBinding
-import dev.msfjarvis.aps.util.proxy.ProxyUtils
-import dev.msfjarvis.aps.util.settings.GitSettings
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import javax.inject.Inject
-
-private val WEB_ADDRESS_REGEX = Patterns.WEB_URL.toRegex()
-
-@AndroidEntryPoint
-class ProxySelectorActivity : AppCompatActivity() {
-
- @Inject lateinit var gitSettings: GitSettings
- @ProxyPreferences @Inject lateinit var proxyPrefs: SharedPreferences
- @Inject lateinit var proxyUtils: ProxyUtils
- private val binding by viewBinding(ActivityProxySelectorBinding::inflate)
- 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 (isNumericAddress(text) || text.matches(WEB_ADDRESS_REGEX)) {
- null
- } else {
- getString(R.string.invalid_proxy_url)
- }
- }
- }
- }
- }
-
- private fun isNumericAddress(text: CharSequence): Boolean {
- return if (Build.VERSION.SDK_INT >= 29) {
- InetAddresses.isNumericAddress(text as String)
- } else {
- @Suppress("DEPRECATION") Patterns.IP_ADDRESS.matcher(text).matches()
- }
- }
-
- 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/AutofillSettings.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/AutofillSettings.kt
deleted file mode 100644
index 80dce577..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/AutofillSettings.kt
+++ /dev/null
@@ -1,133 +0,0 @@
-/*
- * Copyright © 2014-2021 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.Intent
-import android.net.Uri
-import android.os.Build
-import android.provider.Settings
-import androidx.annotation.RequiresApi
-import androidx.appcompat.widget.AppCompatTextView
-import androidx.fragment.app.FragmentActivity
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.LifecycleEventObserver
-import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel
-import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import de.Maxr1998.modernpreferences.PreferenceScreen
-import de.Maxr1998.modernpreferences.helpers.editText
-import de.Maxr1998.modernpreferences.helpers.onClick
-import de.Maxr1998.modernpreferences.helpers.singleChoice
-import de.Maxr1998.modernpreferences.helpers.switch
-import de.Maxr1998.modernpreferences.preferences.SwitchPreference
-import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem
-import dev.msfjarvis.aps.BuildConfig
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.util.autofill.DirectoryStructure
-import dev.msfjarvis.aps.util.extensions.autofillManager
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-
-class AutofillSettings(private val activity: FragmentActivity) : SettingsProvider {
-
- private val isAutofillServiceEnabled: Boolean
- get() {
- if (Build.VERSION.SDK_INT < 26) return false
- return activity.autofillManager?.hasEnabledAutofillServices() == true
- }
-
- @RequiresApi(26)
- private fun showAutofillDialog(pref: SwitchPreference) {
- val observer = LifecycleEventObserver { _, event ->
- when (event) {
- Lifecycle.Event.ON_RESUME -> {
- pref.checked = isAutofillServiceEnabled
- }
- else -> {}
- }
- }
- MaterialAlertDialogBuilder(activity).run {
- setTitle(R.string.pref_autofill_enable_title)
- @SuppressLint("InflateParams")
- val layout = activity.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 ->
- activity.getString(R.string.oreo_autofill_no_support)
- BrowserAutofillSupportLevel.FlakyFill ->
- activity.getString(R.string.oreo_autofill_flaky_fill_support)
- BrowserAutofillSupportLevel.PasswordFill ->
- activity.getString(R.string.oreo_autofill_password_fill_support)
- BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility ->
- activity.getString(
- R.string.oreo_autofill_password_fill_and_conditional_save_support
- )
- BrowserAutofillSupportLevel.GeneralFill ->
- activity.getString(R.string.oreo_autofill_general_fill_support)
- BrowserAutofillSupportLevel.GeneralFillAndSave ->
- activity.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}")
- }
- activity.startActivity(intent)
- }
- setNegativeButton(R.string.dialog_cancel, null)
- setOnDismissListener { pref.checked = isAutofillServiceEnabled }
- activity.lifecycle.addObserver(observer)
- show()
- }
- }
-
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- builder.apply {
- switch(PreferenceKeys.AUTOFILL_ENABLE) {
- titleRes = R.string.pref_autofill_enable_title
- visible = Build.VERSION.SDK_INT >= 26
- defaultValue = isAutofillServiceEnabled
- onClick {
- if (Build.VERSION.SDK_INT < 26) return@onClick true
- if (isAutofillServiceEnabled) {
- activity.autofillManager?.disableAutofillServices()
- } else {
- showAutofillDialog(this)
- }
- false
- }
- }
- val values =
- activity.resources.getStringArray(R.array.oreo_autofill_directory_structure_values)
- val titles =
- activity.resources.getStringArray(R.array.oreo_autofill_directory_structure_entries)
- val items = values.zip(titles).map { SelectionItem(it.first, it.second, null) }
- singleChoice(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE, items) {
- initialSelection = DirectoryStructure.DEFAULT.value
- dependency = PreferenceKeys.AUTOFILL_ENABLE
- titleRes = R.string.oreo_autofill_preference_directory_structure
- }
- editText(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) {
- dependency = PreferenceKeys.AUTOFILL_ENABLE
- titleRes = R.string.preference_default_username_title
- summaryProvider = { activity.getString(R.string.preference_default_username_summary) }
- }
- editText(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) {
- dependency = PreferenceKeys.AUTOFILL_ENABLE
- titleRes = R.string.preference_custom_public_suffixes_title
- summaryProvider = { activity.getString(R.string.preference_custom_public_suffixes_summary) }
- textInputHintRes = R.string.preference_custom_public_suffixes_hint
- }
- }
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/GeneralSettings.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/GeneralSettings.kt
deleted file mode 100644
index 21ecd55c..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/GeneralSettings.kt
+++ /dev/null
@@ -1,110 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.ui.settings
-
-import android.content.pm.ShortcutManager
-import android.os.Build
-import androidx.core.content.edit
-import androidx.core.content.getSystemService
-import androidx.fragment.app.FragmentActivity
-import de.Maxr1998.modernpreferences.PreferenceScreen
-import de.Maxr1998.modernpreferences.helpers.checkBox
-import de.Maxr1998.modernpreferences.helpers.onClick
-import de.Maxr1998.modernpreferences.helpers.singleChoice
-import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.util.auth.BiometricAuthenticator
-import dev.msfjarvis.aps.util.auth.BiometricAuthenticator.Result
-import dev.msfjarvis.aps.util.extensions.sharedPrefs
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-
-class GeneralSettings(private val activity: FragmentActivity) : SettingsProvider {
-
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- builder.apply {
- val themeValues = activity.resources.getStringArray(R.array.app_theme_values)
- val themeOptions = activity.resources.getStringArray(R.array.app_theme_options)
- val themeItems =
- themeValues.zip(themeOptions).map { SelectionItem(it.first, it.second, null) }
- singleChoice(PreferenceKeys.APP_THEME, themeItems) {
- initialSelection = activity.resources.getString(R.string.app_theme_def)
- titleRes = R.string.pref_app_theme_title
- }
-
- val sortValues = activity.resources.getStringArray(R.array.sort_order_values)
- val sortOptions = activity.resources.getStringArray(R.array.sort_order_entries)
- val sortItems = sortValues.zip(sortOptions).map { SelectionItem(it.first, it.second, null) }
- singleChoice(PreferenceKeys.SORT_ORDER, sortItems) {
- initialSelection = sortValues[0]
- titleRes = R.string.pref_sort_order_title
- }
-
- checkBox(PreferenceKeys.DISABLE_SYNC_ACTION) {
- titleRes = R.string.pref_disable_sync_on_pull_title
- summaryRes = R.string.pref_disable_sync_on_pull_summary
- defaultValue = false
- }
-
- checkBox(PreferenceKeys.FILTER_RECURSIVELY) {
- titleRes = R.string.pref_recursive_filter_title
- summaryRes = R.string.pref_recursive_filter_summary
- defaultValue = true
- }
-
- checkBox(PreferenceKeys.SEARCH_ON_START) {
- titleRes = R.string.pref_search_on_start_title
- summaryRes = R.string.pref_search_on_start_summary
- defaultValue = false
- }
-
- checkBox(PreferenceKeys.SHOW_HIDDEN_CONTENTS) {
- titleRes = R.string.pref_show_hidden_title
- summaryRes = R.string.pref_show_hidden_summary
- defaultValue = false
- }
-
- val canAuthenticate = BiometricAuthenticator.canAuthenticate(activity)
- checkBox(PreferenceKeys.BIOMETRIC_AUTH) {
- titleRes = R.string.pref_biometric_auth_title
- defaultValue = false
- enabled = canAuthenticate
- summaryRes =
- if (canAuthenticate) R.string.pref_biometric_auth_summary
- else R.string.pref_biometric_auth_summary_error
- onClick {
- enabled = false
- val isChecked = checked
- activity.sharedPrefs.edit {
- BiometricAuthenticator.authenticate(activity) { result ->
- when (result) {
- is Result.Success -> {
- // Apply the changes
- putBoolean(PreferenceKeys.BIOMETRIC_AUTH, checked)
- enabled = true
- }
- is Result.Retry -> {}
- else -> {
- // If any error occurs, revert back to the previous
- // state. This
- // catch-all clause includes the cancellation case.
- putBoolean(PreferenceKeys.BIOMETRIC_AUTH, !checked)
- checked = !isChecked
- enabled = true
- }
- }
- }
- }
- if (Build.VERSION.SDK_INT >= 25) {
- activity.getSystemService<ShortcutManager>()?.apply {
- removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
- }
- }
- false
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/MiscSettings.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/MiscSettings.kt
deleted file mode 100644
index 5bb50cd8..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/MiscSettings.kt
+++ /dev/null
@@ -1,81 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.ui.settings
-
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
-import android.os.Build
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.documentfile.provider.DocumentFile
-import androidx.fragment.app.FragmentActivity
-import de.Maxr1998.modernpreferences.PreferenceScreen
-import de.Maxr1998.modernpreferences.helpers.checkBox
-import de.Maxr1998.modernpreferences.helpers.onClick
-import de.Maxr1998.modernpreferences.helpers.pref
-import dev.msfjarvis.aps.BuildConfig
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.util.services.PasswordExportService
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-
-class MiscSettings(activity: FragmentActivity) : SettingsProvider {
-
- private val storeExportAction =
- activity.registerForActivityResult(
- object : ActivityResultContracts.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(activity.applicationContext, uri)
-
- if (targetDirectory != null) {
- val service =
- Intent(activity.applicationContext, PasswordExportService::class.java).apply {
- action = PasswordExportService.ACTION_EXPORT_PASSWORD
- putExtra("uri", uri)
- }
-
- if (Build.VERSION.SDK_INT >= 26) {
- activity.startForegroundService(service)
- } else {
- activity.startService(service)
- }
- }
- }
-
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- builder.apply {
- pref(PreferenceKeys.EXPORT_PASSWORDS) {
- titleRes = R.string.prefs_export_passwords_title
- summaryRes = R.string.prefs_export_passwords_summary
- onClick {
- storeExportAction.launch(null)
- true
- }
- }
- checkBox(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY) {
- defaultValue = false
- titleRes = R.string.pref_clear_clipboard_title
- summaryRes = R.string.pref_clear_clipboard_summary
- }
- checkBox(PreferenceKeys.ENABLE_DEBUG_LOGGING) {
- defaultValue = false
- titleRes = R.string.pref_debug_logging_title
- summaryRes = R.string.pref_debug_logging_summary
- visible = !BuildConfig.DEBUG
- }
- }
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/PGPSettings.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/PGPSettings.kt
deleted file mode 100644
index b81af006..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/PGPSettings.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.ui.settings
-
-import androidx.fragment.app.FragmentActivity
-import de.Maxr1998.modernpreferences.PreferenceScreen
-import de.Maxr1998.modernpreferences.helpers.checkBox
-import de.Maxr1998.modernpreferences.helpers.onClick
-import de.Maxr1998.modernpreferences.helpers.pref
-import dev.msfjarvis.aps.ui.pgp.PGPKeyImportActivity
-import dev.msfjarvis.aps.util.extensions.launchActivity
-import dev.msfjarvis.aps.util.features.Feature
-
-class PGPSettings(private val activity: FragmentActivity) : SettingsProvider {
-
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- builder.apply {
- val enablePGPainless =
- checkBox(Feature.EnablePGPainlessBackend.configKey) {
- title = "Enable new PGP backend"
- persistent = true
- }
- pref("_") {
- title = "Import PGP key"
- persistent = false
- dependency = enablePGPainless.key
- onClick {
- activity.launchActivity(PGPKeyImportActivity::class.java)
- false
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/PasswordSettings.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/PasswordSettings.kt
deleted file mode 100644
index db3d3670..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/PasswordSettings.kt
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.ui.settings
-
-import android.text.InputType
-import androidx.fragment.app.FragmentActivity
-import de.Maxr1998.modernpreferences.PreferenceScreen
-import de.Maxr1998.modernpreferences.helpers.checkBox
-import de.Maxr1998.modernpreferences.helpers.editText
-import de.Maxr1998.modernpreferences.helpers.onSelectionChange
-import de.Maxr1998.modernpreferences.helpers.singleChoice
-import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-
-class PasswordSettings(private val activity: FragmentActivity) : SettingsProvider {
-
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- builder.apply {
- val values = activity.resources.getStringArray(R.array.pwgen_provider_values)
- val labels = activity.resources.getStringArray(R.array.pwgen_provider_labels)
- val items = values.zip(labels).map { SelectionItem(it.first, it.second, null) }
- singleChoice(
- PreferenceKeys.PREF_KEY_PWGEN_TYPE,
- items,
- ) {
- initialSelection = "classic"
- titleRes = R.string.pref_password_generator_type_title
- onSelectionChange { true }
- }
- editText(PreferenceKeys.GENERAL_SHOW_TIME) {
- titleRes = R.string.pref_clipboard_timeout_title
- summaryProvider = { timeout ->
- activity.getString(R.string.pref_clipboard_timeout_summary, timeout ?: "45")
- }
- textInputType = InputType.TYPE_CLASS_NUMBER
- }
- checkBox(PreferenceKeys.SHOW_PASSWORD) {
- titleRes = R.string.show_password_pref_title
- summaryRes = R.string.show_password_pref_summary
- defaultValue = true
- }
- checkBox(PreferenceKeys.COPY_ON_DECRYPT) {
- titleRes = R.string.pref_copy_title
- summaryRes = R.string.pref_copy_summary
- defaultValue = false
- }
- }
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/RepositorySettings.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/RepositorySettings.kt
deleted file mode 100644
index df34b145..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/RepositorySettings.kt
+++ /dev/null
@@ -1,194 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.ui.settings
-
-import android.content.Intent
-import android.content.SharedPreferences
-import android.content.pm.ShortcutManager
-import android.os.Build
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.core.content.edit
-import androidx.core.content.getSystemService
-import androidx.fragment.app.FragmentActivity
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import dagger.hilt.EntryPoint
-import dagger.hilt.InstallIn
-import dagger.hilt.android.EntryPointAccessors
-import dagger.hilt.components.SingletonComponent
-import de.Maxr1998.modernpreferences.Preference
-import de.Maxr1998.modernpreferences.PreferenceScreen
-import de.Maxr1998.modernpreferences.helpers.checkBox
-import de.Maxr1998.modernpreferences.helpers.onClick
-import de.Maxr1998.modernpreferences.helpers.pref
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.data.repo.PasswordRepository
-import dev.msfjarvis.aps.injection.prefs.GitPreferences
-import dev.msfjarvis.aps.ui.git.config.GitConfigActivity
-import dev.msfjarvis.aps.ui.git.config.GitServerConfigActivity
-import dev.msfjarvis.aps.ui.proxy.ProxySelectorActivity
-import dev.msfjarvis.aps.ui.sshkeygen.ShowSshKeyFragment
-import dev.msfjarvis.aps.ui.sshkeygen.SshKeyGenActivity
-import dev.msfjarvis.aps.ui.sshkeygen.SshKeyImportActivity
-import dev.msfjarvis.aps.util.extensions.getString
-import dev.msfjarvis.aps.util.extensions.launchActivity
-import dev.msfjarvis.aps.util.extensions.sharedPrefs
-import dev.msfjarvis.aps.util.extensions.snackbar
-import dev.msfjarvis.aps.util.extensions.unsafeLazy
-import dev.msfjarvis.aps.util.git.sshj.SshKey
-import dev.msfjarvis.aps.util.settings.GitSettings
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-
-class RepositorySettings(private val activity: FragmentActivity) : SettingsProvider {
-
- private val generateSshKey =
- activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
- showSshKeyPref?.visible = SshKey.canShowSshPublicKey
- }
-
- private val hiltEntryPoint by unsafeLazy {
- EntryPointAccessors.fromApplication(
- activity.applicationContext,
- RepositorySettingsEntryPoint::class.java,
- )
- }
-
- private var showSshKeyPref: Preference? = null
-
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- val encryptedPreferences = hiltEntryPoint.encryptedPreferences()
- val gitSettings = hiltEntryPoint.gitSettings()
-
- builder.apply {
- checkBox(PreferenceKeys.REBASE_ON_PULL) {
- titleRes = R.string.pref_rebase_on_pull_title
- summaryRes = R.string.pref_rebase_on_pull_summary
- summaryOnRes = R.string.pref_rebase_on_pull_summary_on
- defaultValue = true
- }
- pref(PreferenceKeys.GIT_SERVER_INFO) {
- titleRes = R.string.pref_edit_git_server_settings
- visible = PasswordRepository.isGitRepo()
- onClick {
- activity.launchActivity(GitServerConfigActivity::class.java)
- true
- }
- }
- pref(PreferenceKeys.PROXY_SETTINGS) {
- titleRes = R.string.pref_edit_proxy_settings
- visible = gitSettings.url?.startsWith("https") == true && PasswordRepository.isGitRepo()
- onClick {
- activity.launchActivity(ProxySelectorActivity::class.java)
- true
- }
- }
- pref(PreferenceKeys.GIT_CONFIG) {
- titleRes = R.string.pref_edit_git_config
- visible = PasswordRepository.isGitRepo()
- onClick {
- activity.launchActivity(GitConfigActivity::class.java)
- true
- }
- }
- pref(PreferenceKeys.SSH_KEY) {
- titleRes = R.string.pref_import_ssh_key_title
- visible = PasswordRepository.isGitRepo()
- onClick {
- activity.launchActivity(SshKeyImportActivity::class.java)
- true
- }
- }
- pref(PreferenceKeys.SSH_KEYGEN) {
- titleRes = R.string.pref_ssh_keygen_title
- onClick {
- generateSshKey.launch(Intent(activity, SshKeyGenActivity::class.java))
- true
- }
- }
- showSshKeyPref =
- pref(PreferenceKeys.SSH_SEE_KEY) {
- titleRes = R.string.pref_ssh_see_key_title
- visible = PasswordRepository.isGitRepo() && SshKey.canShowSshPublicKey
- onClick {
- ShowSshKeyFragment().show(activity.supportFragmentManager, "public_key")
- true
- }
- }
- pref(PreferenceKeys.CLEAR_SAVED_PASS) {
- fun Preference.updatePref() {
- val sshPass = encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE)
- val httpsPass = encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD)
- if (sshPass == null && httpsPass == null) {
- visible = false
- return
- }
- when {
- httpsPass != null -> titleRes = R.string.clear_saved_passphrase_https
- sshPass != null -> titleRes = R.string.clear_saved_passphrase_ssh
- }
- visible = true
- requestRebind()
- }
- onClick {
- updatePref()
- true
- }
- updatePref()
- }
- pref(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID) {
- titleRes = R.string.pref_title_openkeystore_clear_keyid
- visible =
- activity.sharedPrefs.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty()
- ?: false
- onClick {
- activity.sharedPrefs.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) }
- visible = false
- true
- }
- }
- pref(PreferenceKeys.GIT_DELETE_REPO) {
- titleRes = R.string.pref_git_delete_repo_title
- summaryRes = R.string.pref_git_delete_repo_summary
- onClick {
- val repoDir = PasswordRepository.getRepositoryDirectory()
- MaterialAlertDialogBuilder(activity)
- .setTitle(R.string.pref_dialog_delete_title)
- .setMessage(activity.getString(R.string.dialog_delete_msg, repoDir))
- .setCancelable(false)
- .setPositiveButton(R.string.dialog_delete) { dialogInterface, _ ->
- runCatching {
- PasswordRepository.getRepositoryDirectory().deleteRecursively()
- PasswordRepository.closeRepository()
- }
- .onFailure { it.message?.let { message -> activity.snackbar(message = message) } }
-
- if (Build.VERSION.SDK_INT >= 25) {
- activity.getSystemService<ShortcutManager>()?.apply {
- removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
- }
- }
- activity.sharedPrefs.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) }
- dialogInterface.cancel()
- activity.finish()
- }
- .setNegativeButton(R.string.dialog_do_not_delete) { dialogInterface, _ ->
- run { dialogInterface.cancel() }
- }
- .show()
- true
- }
- }
- }
- }
-
- @EntryPoint
- @InstallIn(SingletonComponent::class)
- interface RepositorySettingsEntryPoint {
- fun gitSettings(): GitSettings
- @GitPreferences fun encryptedPreferences(): SharedPreferences
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsActivity.kt
deleted file mode 100644
index d31aa630..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsActivity.kt
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.ui.settings
-
-import android.os.Bundle
-import android.view.MenuItem
-import androidx.appcompat.app.AppCompatActivity
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import de.Maxr1998.modernpreferences.Preference
-import de.Maxr1998.modernpreferences.PreferencesAdapter
-import de.Maxr1998.modernpreferences.helpers.screen
-import de.Maxr1998.modernpreferences.helpers.subScreen
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.databinding.ActivityPreferenceRecyclerviewBinding
-import dev.msfjarvis.aps.util.extensions.viewBinding
-
-class SettingsActivity : AppCompatActivity() {
-
- private val miscSettings = MiscSettings(this)
- private val autofillSettings = AutofillSettings(this)
- private val passwordSettings = PasswordSettings(this)
- private val repositorySettings = RepositorySettings(this)
- private val generalSettings = GeneralSettings(this)
- private val pgpSettings = PGPSettings(this)
-
- private val binding by viewBinding(ActivityPreferenceRecyclerviewBinding::inflate)
- private val preferencesAdapter: PreferencesAdapter
- get() = binding.preferenceRecyclerView.adapter as PreferencesAdapter
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(binding.root)
- Preference.Config.dialogBuilderFactory = { context -> MaterialAlertDialogBuilder(context) }
- val screen =
- screen(this) {
- subScreen {
- titleRes = R.string.pref_category_general_title
- iconRes = R.drawable.app_settings_alt_24px
- generalSettings.provideSettings(this)
- }
- subScreen {
- titleRes = R.string.pref_category_autofill_title
- iconRes = R.drawable.ic_wysiwyg_24px
- autofillSettings.provideSettings(this)
- }
- subScreen {
- titleRes = R.string.pref_category_passwords_title
- iconRes = R.drawable.ic_password_24px
- passwordSettings.provideSettings(this)
- }
- subScreen {
- titleRes = R.string.pref_category_repository_title
- iconRes = R.drawable.ic_call_merge_24px
- repositorySettings.provideSettings(this)
- }
- subScreen {
- titleRes = R.string.pref_category_misc_title
- iconRes = R.drawable.ic_miscellaneous_services_24px
- miscSettings.provideSettings(this)
- }
- subScreen {
- titleRes = R.string.pref_category_pgp_title
- iconRes = R.drawable.ic_lock_open_24px
- pgpSettings.provideSettings(this)
- }
- }
- val adapter = PreferencesAdapter(screen)
- adapter.onScreenChangeListener =
- PreferencesAdapter.OnScreenChangeListener { subScreen, entering ->
- supportActionBar?.title =
- if (!entering) {
- getString(R.string.action_settings)
- } else {
- getString(subScreen.titleRes)
- }
- }
- savedInstanceState
- ?.getParcelable<PreferencesAdapter.SavedState>("adapter")
- ?.let(adapter::loadSavedState)
- binding.preferenceRecyclerView.adapter = adapter
- }
-
- override fun onSaveInstanceState(outState: Bundle) {
- super.onSaveInstanceState(outState)
- outState.putParcelable("adapter", preferencesAdapter.getSavedState())
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- android.R.id.home ->
- if (!preferencesAdapter.goBack()) {
- super.onOptionsItemSelected(item)
- } else {
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
- override fun onBackPressed() {
- if (!preferencesAdapter.goBack()) super.onBackPressed()
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsProvider.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsProvider.kt
deleted file mode 100644
index 61b11064..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsProvider.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.ui.settings
-
-import de.Maxr1998.modernpreferences.PreferenceScreen
-
-/** Used to generate a uniform API for all settings UI classes. */
-interface SettingsProvider {
-
- /** Inserts the settings items for the class into the given [builder]. */
- fun provideSettings(builder: PreferenceScreen.Builder)
-}
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
deleted file mode 100644
index 73cd2ba1..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/ShowSshKeyFragment.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * Copyright © 2014-2021 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
deleted file mode 100644
index dec0c135..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyGenActivity.kt
+++ /dev/null
@@ -1,172 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.ui.sshkeygen
-
-import android.content.SharedPreferences
-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 dagger.hilt.android.AndroidEntryPoint
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.databinding.ActivitySshKeygenBinding
-import dev.msfjarvis.aps.injection.prefs.GitPreferences
-import dev.msfjarvis.aps.util.auth.BiometricAuthenticator
-import dev.msfjarvis.aps.util.auth.BiometricAuthenticator.Result
-import dev.msfjarvis.aps.util.extensions.keyguardManager
-import dev.msfjarvis.aps.util.extensions.viewBinding
-import dev.msfjarvis.aps.util.git.sshj.SshKey
-import javax.inject.Inject
-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)
- }),
-}
-
-@AndroidEntryPoint
-class SshKeyGenActivity : AppCompatActivity() {
-
- private var keyGenType = KeyGenType.Ecdsa
- private val binding by viewBinding(ActivitySshKeygenBinding::inflate)
- @GitPreferences @Inject lateinit var gitPrefs: SharedPreferences
-
- 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) { _, _ ->
- setResult(RESULT_CANCELED)
- 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 {
- 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<Result> { cont ->
- BiometricAuthenticator.authenticate(
- this@SshKeyGenActivity,
- R.string.biometric_prompt_title_ssh_keygen
- ) { result ->
- // Do not cancel on failed attempts as these are handled by the
- // authenticator UI.
- if (result !is Result.Retry) cont.resume(result)
- }
- }
- }
- if (result !is Result.Success)
- throw UserNotAuthenticatedException(getString(R.string.biometric_auth_generic_failure))
- }
- keyGenType.generateKey(requireAuthentication)
- }
- }
- gitPrefs.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)) { _, _ ->
- setResult(RESULT_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/sshkeygen/SshKeyImportActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyImportActivity.kt
deleted file mode 100644
index 446e0c32..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyImportActivity.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.ui.sshkeygen
-
-import android.net.Uri
-import android.os.Bundle
-import android.widget.Toast
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.appcompat.app.AppCompatActivity
-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.util.git.sshj.SshKey
-
-class SshKeyImportActivity : AppCompatActivity() {
-
- private val sshKeyImportAction =
- registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
- if (uri == null) {
- finish()
- 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)) { _, _ -> finish() }
- .show()
- }
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- 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) { _, _ -> finish() }
- setOnCancelListener { finish() }
- show()
- }
- } else {
- importSshKey()
- }
- }
-
- private fun importSshKey() {
- sshKeyImportAction.launch(arrayOf("*/*"))
- }
-}
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
deleted file mode 100644
index 494a9ed7..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- * Copyright © 2014-2021 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
deleted file mode 100644
index 38ff812a..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt
+++ /dev/null
@@ -1,131 +0,0 @@
-/*
- * Copyright © 2014-2021 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 dev.msfjarvis.aps.R
-import logcat.logcat
-
-object BiometricAuthenticator {
-
- private const val TAG = "BiometricAuthenticator"
- private const val validAuthenticators =
- Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK
-
- /**
- * Sealed class to wrap [BiometricPrompt]'s [Int]-based return codes into more easily-interpreted
- * types.
- */
- sealed class Result {
-
- /** Biometric authentication was a success. */
- data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result()
-
- /** Biometric authentication has irreversibly failed. */
- data class Failure(val code: Int?, val message: CharSequence) : Result()
-
- /**
- * An incorrect biometric was entered, but the prompt UI is offering the option to retry the
- * operation.
- */
- object Retry : Result()
-
- /** The biometric hardware is unavailable or disabled on a software or hardware level. */
- object HardwareUnavailableOrDisabled : Result()
-
- /** The prompt was dismissed. */
- 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)
- logcat(TAG) { "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
- }
- BiometricPrompt.ERROR_LOCKOUT,
- BiometricPrompt.ERROR_LOCKOUT_PERMANENT,
- BiometricPrompt.ERROR_NO_SPACE,
- BiometricPrompt.ERROR_TIMEOUT,
- BiometricPrompt.ERROR_VENDOR -> {
- Result.Failure(
- errorCode,
- activity.getString(R.string.biometric_auth_error_reason, errString)
- )
- }
- BiometricPrompt.ERROR_UNABLE_TO_PROCESS -> {
- Result.Retry
- }
- // We cover all guaranteed values above, but [errorCode] is still an Int
- // at the end of
- // the day so a
- // catch-all else will always be required.
- else -> {
- Result.Failure(
- errorCode,
- activity.getString(R.string.biometric_auth_error_reason, errString)
- )
- }
- }
- )
- }
-
- override fun onAuthenticationFailed() {
- super.onAuthenticationFailed()
- callback(Result.Retry)
- }
-
- 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
deleted file mode 100644
index 745bad73..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt
+++ /dev/null
@@ -1,236 +0,0 @@
-/*
- * Copyright © 2014-2021 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.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.androidpasswordstore.autofillparser.AutofillAction
-import com.github.androidpasswordstore.autofillparser.FillableForm
-import com.github.androidpasswordstore.autofillparser.fillWith
-import com.github.michaelbull.result.fold
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedFactory
-import dagger.assisted.AssistedInject
-import dev.msfjarvis.aps.autofill.oreo.ui.AutofillSmsActivity
-import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivity
-import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivityV2
-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.util.features.Feature
-import dev.msfjarvis.aps.util.features.Features
-import java.io.File
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
-
-/** Implements [AutofillResponseBuilder]'s methods for API 30 and above */
-@RequiresApi(30)
-class Api30AutofillResponseBuilder
-@AssistedInject
-constructor(
- @Assisted form: FillableForm,
- private val features: Features,
-) {
-
- @AssistedFactory
- interface Factory {
- fun create(form: FillableForm): Api30AutofillResponseBuilder
- }
-
- 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 =
- if (features.isEnabled(Feature.EnablePGPainlessBackend)) {
- AutofillDecryptActivityV2.makeDecryptFileIntentSender(file, context)
- } else {
- 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)
- }
- }
- makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
- datasetCount++
- addDataset(it)
- }
- makeFillOtpFromSmsDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
- datasetCount++
- addDataset(it)
- }
- makeSearchDataset(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 ->
- logcat(ERROR) { e.asLog() }
- 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
deleted file mode 100644
index 42c1d693..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt
+++ /dev/null
@@ -1,202 +0,0 @@
-/*
- * Copyright © 2014-2021 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.androidpasswordstore.autofillparser.FormOrigin
-import com.github.androidpasswordstore.autofillparser.computeCertificatesHash
-import com.github.michaelbull.result.Err
-import com.github.michaelbull.result.Ok
-import com.github.michaelbull.result.Result
-import dev.msfjarvis.aps.R
-import java.io.File
-import logcat.LogPriority.ERROR
-import logcat.LogPriority.WARN
-import logcat.logcat
-
-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) {
- logcat(ERROR) { "$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.
- logcat(ERROR) { "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)
- logcat { "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) {
- logcat(WARN) { "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
- logcat { "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
deleted file mode 100644
index 6803da47..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt
+++ /dev/null
@@ -1,148 +0,0 @@
-/*
- * Copyright © 2014-2021 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 androidx.annotation.RequiresApi
-import com.github.androidpasswordstore.autofillparser.Credentials
-import dev.msfjarvis.aps.data.passfile.PasswordEntry
-import dev.msfjarvis.aps.util.extensions.getString
-import dev.msfjarvis.aps.util.extensions.sharedPrefs
-import dev.msfjarvis.aps.util.services.getDefaultUsername
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import java.io.File
-import java.nio.file.Paths
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.runBlocking
-
-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(26)
- 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 {
-
- 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(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE)
- 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()
- val totp = if (entry.hasTotp()) runBlocking { entry.totp.first().value } else null
- return Credentials(username, entry.password, totp)
- }
-}
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
deleted file mode 100644
index a091e206..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt
+++ /dev/null
@@ -1,233 +0,0 @@
-/*
- * Copyright © 2014-2021 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.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 dagger.assisted.Assisted
-import dagger.assisted.AssistedFactory
-import dagger.assisted.AssistedInject
-import dev.msfjarvis.aps.autofill.oreo.ui.AutofillSmsActivity
-import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivity
-import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivityV2
-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.util.features.Feature
-import dev.msfjarvis.aps.util.features.Features
-import java.io.File
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
-
-@RequiresApi(26)
-class AutofillResponseBuilder
-@AssistedInject
-constructor(
- @Assisted form: FillableForm,
- private val features: Features,
-) {
-
- @AssistedFactory
- interface Factory {
- fun create(form: FillableForm): AutofillResponseBuilder
- }
-
- 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 =
- if (features.isEnabled(Feature.EnablePGPainlessBackend)) {
- AutofillDecryptActivityV2.makeDecryptFileIntentSender(file, context)
- } else {
- 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)
- }
- }
- makeGenerateDataset(context)?.let {
- datasetCount++
- addDataset(it)
- }
- makeFillOtpFromSmsDataset(context)?.let {
- datasetCount++
- addDataset(it)
- }
- makeSearchDataset(context)?.let {
- datasetCount++
- addDataset(it)
- }
- if (datasetCount == 0) return null
- if (Build.VERSION.SDK_INT >= 28) {
- 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 ->
- logcat(ERROR) { e.asLog() }
- 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 >= 28) {
- Dataset.Builder()
- } else {
- Dataset.Builder(makeRemoteView(context, makeEmptyMetadata()))
- }
- return builder.run {
- if (scenario != null) fillWith(scenario, action, credentials)
- else logcat(ERROR) { "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
deleted file mode 100644
index 20414e6f..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt
+++ /dev/null
@@ -1,120 +0,0 @@
-/*
- * Copyright © 2014-2021 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.R
-import dev.msfjarvis.aps.data.repo.PasswordRepository
-import dev.msfjarvis.aps.ui.passwords.PasswordStore
-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 < 30) 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),
- if (Build.VERSION.SDK_INT >= 31) {
- PendingIntent.FLAG_MUTABLE
- } else {
- 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/crypto/GpgIdentifier.kt b/app/src/main/java/dev/msfjarvis/aps/util/crypto/GpgIdentifier.kt
deleted file mode 100644
index f99b623c..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/crypto/GpgIdentifier.kt
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.crypto
-
-import me.msfjarvis.openpgpktx.util.OpenPgpUtils
-
-sealed class GpgIdentifier {
- data class KeyId(val id: Long) : GpgIdentifier()
- data class UserId(val email: String) : GpgIdentifier()
-
- companion object {
- @OptIn(ExperimentalUnsignedTypes::class)
- fun fromString(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 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 KeyId(keyId.toLong())
- }
-
- return OpenPgpUtils.splitUserId(identifier).email?.let { UserId(it) }
- }
- }
-}
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
deleted file mode 100644
index cc243a09..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt
+++ /dev/null
@@ -1,133 +0,0 @@
-/*
- * Copyright © 2014-2021 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.Intent
-import android.content.SharedPreferences
-import android.content.pm.PackageManager
-import android.util.Base64
-import android.util.TypedValue
-import android.view.View
-import android.view.autofill.AutofillManager
-import androidx.annotation.RequiresApi
-import androidx.core.content.ContextCompat
-import androidx.core.content.getSystemService
-import androidx.fragment.app.FragmentActivity
-import androidx.security.crypto.EncryptedSharedPreferences
-import androidx.security.crypto.MasterKey
-import com.github.michaelbull.result.Ok
-import com.github.michaelbull.result.Result
-import com.google.android.material.snackbar.Snackbar
-import dev.msfjarvis.aps.BuildConfig
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.data.repo.PasswordRepository
-import dev.msfjarvis.aps.util.git.operation.GitOperation
-import logcat.logcat
-
-/** Get an instance of [AutofillManager]. Only available on Android Oreo and above */
-val Context.autofillManager: AutofillManager?
- @RequiresApi(26) 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")
-
-/** 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() = getSharedPreferences("${BuildConfig.APPLICATION_ID}_preferences", 0)
-
-/** 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 {
- logcat { "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
-}
-
-/** Launch an activity denoted by [clazz]. */
-fun <T : FragmentActivity> FragmentActivity.launchActivity(clazz: Class<T>) {
- startActivity(Intent(this, clazz))
-}
-
-/** 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
deleted file mode 100644
index 8530a216..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt
+++ /dev/null
@@ -1,98 +0,0 @@
-/*
- * Copyright © 2014-2021 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.Err
-import com.github.michaelbull.result.Ok
-import com.github.michaelbull.result.Result
-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 logcat.asLog
-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()
-}
-
-/** Alias to [lazy] with thread safety mode always set to [LazyThreadSafetyMode.NONE]. */
-fun <T> unsafeLazy(initializer: () -> T) = lazy(LazyThreadSafetyMode.NONE) { initializer.invoke() }
-
-/** A convenience extension to turn a [Throwable] with a message into a loggable string. */
-fun Throwable.asLog(message: String): String = "$message\n${asLog()}"
-
-/** Extension on [Result] that returns if the type is [Ok] */
-fun <V, E> Result<V, E>.isOk(): Boolean {
- return this is Ok<V>
-}
-
-/** Extension on [Result] that returns if the type is [Err] */
-fun <V, E> Result<V, E>.isErr(): Boolean {
- return this is Err<E>
-}
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
deleted file mode 100644
index 3a256ee7..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-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
deleted file mode 100644
index fe885c86..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- * Copyright © 2014-2021 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
-) = unsafeLazy { bindingInflater.invoke(layoutInflater) }
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/features/Feature.kt b/app/src/main/java/dev/msfjarvis/aps/util/features/Feature.kt
deleted file mode 100644
index 4eef003d..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/features/Feature.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
- * Copyright © 2014-2022 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.features
-
-/** List of all feature flags for the app. */
-enum class Feature(
- /** Default value for the flag. */
- val defaultValue: Boolean,
- /** Key to retrieve the current value for the flag. */
- val configKey: String,
-) {
-
- /** Opt into the new PGP backend powered by the PGPainless library. */
- EnablePGPainlessBackend(false, "enable_pgp_v2_backend"),
- ;
-
- companion object {
- @JvmField val VALUES = values()
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/features/Features.kt b/app/src/main/java/dev/msfjarvis/aps/util/features/Features.kt
deleted file mode 100644
index 39b2c3a8..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/features/Features.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-/*
- * Copyright © 2014-2022 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.features
-
-import android.content.SharedPreferences
-import dev.msfjarvis.aps.injection.prefs.SettingsPreferences
-import javax.inject.Inject
-
-class Features
-@Inject
-constructor(
- @SettingsPreferences private val preferences: SharedPreferences,
-) {
-
- fun isEnabled(feature: Feature): Boolean {
- return preferences.getBoolean(feature.configKey, feature.defaultValue)
- }
-}
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
deleted file mode 100644
index 36dc445f..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
- * Copyright © 2014-2021 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_rebase_fail_error)
- object PullMergeFailed : PullException(R.string.git_pull_merge_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
deleted file mode 100644
index e6eb77f5..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt
+++ /dev/null
@@ -1,133 +0,0 @@
-/*
- * Copyright © 2014-2021 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 dagger.hilt.EntryPoint
-import dagger.hilt.InstallIn
-import dagger.hilt.android.EntryPointAccessors
-import dagger.hilt.components.SingletonComponent
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.util.extensions.snackbar
-import dev.msfjarvis.aps.util.extensions.unsafeLazy
-import dev.msfjarvis.aps.util.git.GitException.PullException
-import dev.msfjarvis.aps.util.git.GitException.PushException
-import dev.msfjarvis.aps.util.git.operation.GitOperation
-import dev.msfjarvis.aps.util.settings.GitSettings
-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.StatusCommand
-import org.eclipse.jgit.lib.PersonIdent
-import org.eclipse.jgit.transport.RemoteRefUpdate
-
-class GitCommandExecutor(
- private val activity: FragmentActivity,
- private val operation: GitOperation,
-) {
-
- private val hiltEntryPoint by unsafeLazy {
- EntryPointAccessors.fromApplication(
- activity.applicationContext,
- GitCommandExecutorEntryPoint::class.java
- )
- }
- suspend fun execute(): Result<Unit, Throwable> {
- val gitSettings = hiltEntryPoint.gitSettings()
- 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() }
- if (result.rebaseResult != null) {
- if (!result.rebaseResult.status.isSuccessful) {
- throw PullException.PullRebaseFailed
- }
- } else if (result.mergeResult != null) {
- if (!result.mergeResult.mergeStatus.isSuccessful) {
- throw PullException.PullMergeFailed
- }
- }
- }
- 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() }
- }
-
- @EntryPoint
- @InstallIn(SingletonComponent::class)
- interface GitCommandExecutorEntryPoint {
-
- fun gitSettings(): GitSettings
- }
-}
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
deleted file mode 100644
index 490f5e2c..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommit.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
- * Copyright © 2014-2021 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
deleted file mode 100644
index ed210a65..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.git
-
-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.asLog
-import dev.msfjarvis.aps.util.extensions.hash
-import dev.msfjarvis.aps.util.extensions.time
-import dev.msfjarvis.aps.util.extensions.unsafeLazy
-import logcat.LogPriority.ERROR
-import logcat.logcat
-import org.eclipse.jgit.api.Git
-import org.eclipse.jgit.revwalk.RevCommit
-
-private val TAG = GitLogModel::class.java.simpleName
-
-private fun commits(): Iterable<RevCommit> {
- val repo = PasswordRepository.repository
- if (repo == null) {
- logcat(TAG, ERROR) { "Could not access git repository" }
- return listOf()
- }
- return runCatching { Git(repo).log().call() }
- .getOrElse { e ->
- logcat(TAG, ERROR) { e.asLog("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 unsafeLazy {
- 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)
- logcat(ERROR) { "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
deleted file mode 100644
index 6ea9b8bb..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
- * Copyright © 2014-2021 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.extensions.unsafeLazy
-import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
-import org.eclipse.jgit.api.RebaseCommand
-import org.eclipse.jgit.api.ResetCommand
-import org.eclipse.jgit.lib.RepositoryState
-
-class BreakOutOfDetached(callingActivity: ContinuationContainerActivity) :
- GitOperation(callingActivity) {
-
- private val merging = repository.repositoryState == RepositoryState.MERGING
- private val resetCommands =
- arrayOf(
- // 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 val commands by unsafeLazy {
- if (merging) {
- // We need to run some non-command operations first
- repository.writeMergeCommitMsg(null)
- repository.writeMergeHeads(null)
- arrayOf(
- // reset hard back to our local HEAD
- git.reset().setMode(ResetCommand.ResetType.HARD),
- *resetCommands,
- )
- } else {
- arrayOf(
- // abort the rebase
- git.rebase().setOperation(RebaseCommand.Operation.ABORT),
- *resetCommands,
- )
- }
- }
-
- override fun preExecute() =
- if (!git.repository.repositoryState.isRebasing && !merging) {
- 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
deleted file mode 100644
index 75db2ea5..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- * Copyright © 2014-2021 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
deleted file mode 100644
index 623d69ea..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt
+++ /dev/null
@@ -1,116 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.git.operation
-
-import android.annotation.SuppressLint
-import android.content.SharedPreferences
-import android.view.LayoutInflater
-import android.view.WindowManager
-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 dagger.hilt.EntryPoint
-import dagger.hilt.InstallIn
-import dagger.hilt.android.EntryPointAccessors
-import dagger.hilt.components.SingletonComponent
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.injection.prefs.GitPreferences
-import dev.msfjarvis.aps.util.git.sshj.InteractivePasswordFinder
-import dev.msfjarvis.aps.util.settings.AuthMode
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import kotlin.coroutines.Continuation
-import kotlin.coroutines.resume
-
-class CredentialFinder(val callingActivity: FragmentActivity, val authMode: AuthMode) :
- InteractivePasswordFinder() {
-
- private val hiltEntryPoint =
- EntryPointAccessors.fromApplication(
- callingActivity.applicationContext,
- CredentialFinderEntryPoint::class.java,
- )
-
- override fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) {
- val gitOperationPrefs = hiltEntryPoint.gitPrefs()
- 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 {
- window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
- show()
- }
- } else {
- cont.resume(storedCredential)
- }
- }
-
- @EntryPoint
- @InstallIn(SingletonComponent::class)
- interface CredentialFinderEntryPoint {
- @GitPreferences fun gitPrefs(): SharedPreferences
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GcOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GcOperation.kt
deleted file mode 100644
index c070027e..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GcOperation.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
- * Copyright © 2014-2022 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
-
-/**
- * Run an aggressive garbage collection job on the repository, expiring every loose object to
- * achieve the best compression.
- */
-class GcOperation(
- callingActivity: ContinuationContainerActivity,
-) : GitOperation(callingActivity) {
-
- override val requiresAuth: Boolean = false
- override val commands: Array<GitCommand<out Any>> =
- arrayOf(git.gc().setAggressive(true).setExpire(null))
-}
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
deleted file mode 100644
index 576a802d..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt
+++ /dev/null
@@ -1,245 +0,0 @@
-/*
- * Copyright © 2014-2021 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.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 dagger.hilt.EntryPoint
-import dagger.hilt.InstallIn
-import dagger.hilt.android.EntryPointAccessors
-import dagger.hilt.components.SingletonComponent
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.data.repo.PasswordRepository
-import dev.msfjarvis.aps.ui.sshkeygen.SshKeyGenActivity
-import dev.msfjarvis.aps.ui.sshkeygen.SshKeyImportActivity
-import dev.msfjarvis.aps.util.auth.BiometricAuthenticator
-import dev.msfjarvis.aps.util.auth.BiometricAuthenticator.Result.*
-import dev.msfjarvis.aps.util.git.GitCommandExecutor
-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.settings.AuthMode
-import dev.msfjarvis.aps.util.settings.GitSettings
-import kotlin.coroutines.resume
-import kotlin.coroutines.suspendCoroutine
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
-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) {
-
- /** List of [GitCommand]s that are executed by an operation. */
- abstract val commands: Array<GitCommand<out Any>>
-
- /** Whether the operation requires authentication or not. */
- open val requiresAuth: Boolean = true
- private val hostKeyFile = callingActivity.filesDir.resolve(".host_key")
- private var sshSessionFactory: SshjSessionFactory? = null
- private val hiltEntryPoint =
- EntryPointAccessors.fromApplication(
- callingActivity.applicationContext,
- GitOperationEntryPoint::class.java
- )
-
- protected val repository = PasswordRepository.repository!!
- protected val git = Git(repository)
- protected val remoteBranch = hiltEntryPoint.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 {
- val intent =
- if (make) {
- Intent(callingActivity.applicationContext, SshKeyGenActivity::class.java)
- } else {
- Intent(callingActivity.applicationContext, SshKeyImportActivity::class.java)
- }
- callingActivity.startActivity(intent)
- }
- .onFailure { e -> logcat(ERROR) { e.asLog() } }
- }
-
- 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
- ) { result -> if (result !is Failure) cont.resume(result) }
- }
- }
- when (result) {
- is Success -> {
- registerAuthProviders(SshAuthMethod.SshKey(authActivity))
- }
- is Cancelled -> {
- return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER))
- }
- is 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
- }
-
- @EntryPoint
- @InstallIn(SingletonComponent::class)
- interface GitOperationEntryPoint {
- fun gitSettings(): GitSettings
- }
-}
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
deleted file mode 100644
index 394b7cb4..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Copyright © 2014-2021 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,
- rebase: Boolean,
-) : 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(rebase).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
deleted file mode 100644
index 14f16164..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-/*
- * Copyright © 2014-2021 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
deleted file mode 100644
index 16114f65..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-/*
- * Copyright © 2014-2021 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
deleted file mode 100644
index 589c6305..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-/*
- * Copyright © 2014-2021 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,
- rebase: Boolean,
-) : 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(rebase).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
deleted file mode 100644
index 523ff5b6..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * Copyright © 2014-2021 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
deleted file mode 100644
index 8436d1ce..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt
+++ /dev/null
@@ -1,225 +0,0 @@
-/*
- * Copyright © 2014-2021 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 androidx.lifecycle.lifecycleScope
-import dev.msfjarvis.aps.util.extensions.OPENPGP_PROVIDER
-import dev.msfjarvis.aps.util.extensions.sharedPrefs
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import java.io.Closeable
-import java.security.PublicKey
-import java.security.interfaces.ECKey
-import kotlin.coroutines.resume
-import kotlin.coroutines.suspendCoroutine
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import logcat.logcat
-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) {
- logcat { "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 {
- logcat { "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 { logcat { "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 getParams() = (publicKey as? ECKey)?.params
- }
- }
-
- override fun close() {
- activity.lifecycleScope.launch {
- withContext(Dispatchers.Main) { activity.continueAfterUserInteraction.unregister() }
- }
- sshServiceConnection.disconnect()
- }
-
- override fun getPrivate() = privateKey
-
- override fun getPublic() = publicKey
-
- override fun getType(): KeyType = 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
deleted file mode 100644
index c5cb6eaa..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt
+++ /dev/null
@@ -1,103 +0,0 @@
-/*
- * Copyright © 2014-2021 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 java.security.interfaces.ECKey
-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, ECKey {
-
- 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
deleted file mode 100644
index ea96af53..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt
+++ /dev/null
@@ -1,372 +0,0 @@
-/*
- * Copyright © 2014-2021 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.michaelbull.result.getOrElse
-import com.github.michaelbull.result.runCatching
-import dev.msfjarvis.aps.Application
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs
-import dev.msfjarvis.aps.util.extensions.getString
-import dev.msfjarvis.aps.util.extensions.sharedPrefs
-import dev.msfjarvis.aps.util.extensions.unsafeLazy
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-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 logcat.asLog
-import logcat.logcat
-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 unsafeLazy {
- 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.
- logcat { error.asLog() }
- 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 unsafeLazy {
- if (Build.VERSION.SDK_INT >= 28)
- 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 >= 28) {
- 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 >= 30) {
- 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 ->
- logcat { error.asLog() }
- throw IOException(
- "Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore",
- error
- )
- }
-
- override fun getPrivate(): PrivateKey =
- runCatching { androidKeystore.sshPrivateKey!! }
- .getOrElse { error ->
- logcat { error.asLog() }
- 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 ->
- logcat { error.asLog() }
- 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 ->
- logcat { error.asLog() }
- 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
deleted file mode 100644
index 948fbd35..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt
+++ /dev/null
@@ -1,289 +0,0 @@
-/*
- * Copyright © 2014-2021 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.michaelbull.result.runCatching
-import com.hierynomus.sshj.key.KeyAlgorithms
-import com.hierynomus.sshj.transport.cipher.BlockCiphers
-import com.hierynomus.sshj.transport.cipher.GcmCiphers
-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 logcat.LogPriority.ERROR
-import logcat.LogPriority.INFO
-import logcat.LogPriority.VERBOSE
-import logcat.LogPriority.WARN
-import logcat.asLog
-import logcat.logcat
-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.kex.ECDHNistP
-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)
- }
- logcat("setUpBouncyCastleForSshj") {
- "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 LogcatLoggerFactory : LoggerFactory {
- private class LogcatLogger(name: String) : AbstractLogger(name) {
-
- 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?) {
- logcat(name, VERBOSE) { message.fix().format(*args) + (t?.asLog() ?: "") }
- }
-
- override fun d(message: String, t: Throwable?, vararg args: Any?) {
- logcat(name) { message.fix().format(*args) + (t?.asLog() ?: "") }
- }
-
- override fun i(message: String, t: Throwable?, vararg args: Any?) {
- logcat(name, INFO) { message.fix().format(*args) + (t?.asLog() ?: "") }
- }
-
- override fun w(message: String, t: Throwable?, vararg args: Any?) {
- logcat(name, WARN) { message.fix().format(*args) + (t?.asLog() ?: "") }
- }
-
- override fun e(message: String, t: Throwable?, vararg args: Any?) {
- logcat(name, ERROR) { message.fix().format(*args) + (t?.asLog() ?: "") }
- }
- }
-
- override fun getLogger(name: String): Logger {
- return LogcatLogger(name)
- }
-
- override fun getLogger(clazz: Class<*>): Logger {
- return LogcatLogger(clazz.name)
- }
-}
-
-class SshjConfig : ConfigImpl() {
-
- init {
- loggerFactory = LogcatLoggerFactory
- 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(),
- ECDHNistP.Factory521(),
- ECDHNistP.Factory384(),
- ECDHNistP.Factory256(),
- 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.ECDSASHANistp521(),
- KeyAlgorithms.ECDSASHANistp384(),
- KeyAlgorithms.ECDSASHANistp256(),
- KeyAlgorithms.RSASHA512(),
- KeyAlgorithms.RSASHA256(),
- KeyAlgorithms.SSHRSA(),
- )
- .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(
- GcmCiphers.AES128GCM(),
- GcmCiphers.AES256GCM(),
- 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
deleted file mode 100644
index a9f84fa9..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt
+++ /dev/null
@@ -1,218 +0,0 @@
-/*
- * Copyright © 2014-2021 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.michaelbull.result.getOrElse
-import com.github.michaelbull.result.runCatching
-import dev.msfjarvis.aps.util.git.operation.CredentialFinder
-import dev.msfjarvis.aps.util.settings.AuthMode
-import java.io.File
-import java.io.IOException
-import java.io.InputStream
-import java.io.OutputStream
-import java.security.PublicKey
-import java.util.Collections
-import java.util.concurrent.TimeUnit
-import kotlin.coroutines.Continuation
-import kotlin.coroutines.suspendCoroutine
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.runBlocking
-import logcat.LogPriority.WARN
-import logcat.logcat
-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 {
- logcat { "New SSH connection created" }
- currentSession = it
- }
- }
-
- fun close() {
- currentSession?.close()
- }
-}
-
-private fun makeTofuHostKeyVerifier(hostKeyFile: File): HostKeyVerifier {
- if (!hostKeyFile.exists()) {
- return object : HostKeyVerifier {
- override fun verify(hostname: String?, port: Int, key: PublicKey?): Boolean {
- 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)}"
- logcat(SshjSessionFactory::class.java.simpleName) {
- "Trusting host key on first use: $hostKeyEntry"
- }
- hostKeyFile.writeText(hostKeyEntry)
- return true
- }
-
- override fun findExistingAlgorithms(hostname: String?, port: Int): MutableList<String> {
- return Collections.emptyList()
- }
- }
- } else {
- val hostKeyEntry = hostKeyFile.readText()
- logcat(SshjSessionFactory::class.java.simpleName) { "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.
- logcat { "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 {
- logcat { "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) {
- logcat(WARN) { "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
deleted file mode 100644
index d21ee24d..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- * Copyright © 2014-2021 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
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/** Utility class for [Proxy] handling. */
-@Singleton
-class ProxyUtils @Inject constructor(private val gitSettings: GitSettings) {
-
- /** 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
- }
- }
- }
- )
- }
-
- companion object {
- private const val HTTP_PROXY_USER_PROPERTY = "http.proxyUser"
- private const val HTTP_PROXY_PASSWORD_PROPERTY = "http.proxyPassword"
- }
-}
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
deleted file mode 100644
index 34c3a989..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt
+++ /dev/null
@@ -1,206 +0,0 @@
-/*
- * Copyright © 2014-2021 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 dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.util.extensions.clipboard
-import dev.msfjarvis.aps.util.extensions.sharedPrefs
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-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
-import logcat.logcat
-
-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_HISTORY, false)
- val clipboard = clipboard
-
- if (clipboard != null) {
- scope.launch {
- logcat { "Clearing the clipboard" }
- val clip = ClipData.newPlainText("pgp_handler_result_pm", "")
- clipboard.setPrimaryClip(clip)
- if (deepClear) {
- withContext(Dispatchers.IO) {
- repeat(CLIPBOARD_CLEAR_COUNT) {
- val count = (it * 500).toString()
- clipboard.setPrimaryClip(ClipData.newPlainText(count, count))
- }
- }
- }
- }
- } else {
- logcat { "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 >= 26) {
- PendingIntent.getForegroundService(
- this,
- 0,
- clearIntent,
- if (Build.VERSION.SDK_INT >= 31) {
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
- } else {
- PendingIntent.FLAG_UPDATE_CURRENT
- }
- )
- } else {
- PendingIntent.getService(
- this,
- 0,
- clearIntent,
- if (Build.VERSION.SDK_INT >= 31) {
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
- } else {
- PendingIntent.FLAG_UPDATE_CURRENT
- },
- )
- }
- val notification =
- if (Build.VERSION.SDK_INT <= 23) {
- 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(24)
- 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 >= 26) {
- val serviceChannel =
- NotificationChannel(
- CHANNEL_ID,
- getString(R.string.app_name),
- NotificationManager.IMPORTANCE_LOW
- )
- val manager = getSystemService<NotificationManager>()
- if (manager != null) {
- manager.createNotificationChannel(serviceChannel)
- } else {
- logcat { "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"
- // Newest Samsung phones now feature a history of up to 30 items. To err on the side of
- // caution,
- // push 35 fake ones.
- private const val CLIPBOARD_CLEAR_COUNT = 35
- }
-}
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
deleted file mode 100644
index 3a1c69d3..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt
+++ /dev/null
@@ -1,172 +0,0 @@
-/*
- * Copyright © 2014-2021 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.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 dagger.hilt.android.AndroidEntryPoint
-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.extensions.getString
-import dev.msfjarvis.aps.util.extensions.hasFlag
-import dev.msfjarvis.aps.util.extensions.sharedPrefs
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import javax.inject.Inject
-import logcat.LogPriority.ERROR
-import logcat.logcat
-
-@RequiresApi(26)
-@AndroidEntryPoint
-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
- }
-
- @Inject lateinit var api30ResponseBuilderFactory: Api30AutofillResponseBuilder.Factory
- @Inject lateinit var responseBuilderFactory: AutofillResponseBuilder.Factory
-
- 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 {
- logcat { "Form cannot be filled" }
- callback.onSuccess(null)
- return
- }
- if (Build.VERSION.SDK_INT >= 30) {
- api30ResponseBuilderFactory
- .create(formToFill)
- .fillCredentials(this, request.inlineSuggestionsRequest, callback)
- } else {
- responseBuilderFactory.create(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 {
- logcat(ERROR) { "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 {
- logcat(ERROR) { "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 {
- logcat(ERROR) { "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
deleted file mode 100644
index 91a7ac5f..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt
+++ /dev/null
@@ -1,159 +0,0 @@
-/*
- * Copyright © 2014-2021 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 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
-import logcat.logcat
-
-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)
-
- logcat { "Copying ${repositoryDirectory.path} to $targetDirectory" }
-
- val dateString =
- if (Build.VERSION.SDK_INT >= 26) {
- 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 >= 26) {
- val serviceChannel =
- NotificationChannel(
- CHANNEL_ID,
- getString(R.string.app_name),
- NotificationManager.IMPORTANCE_LOW
- )
- val manager = getSystemService<NotificationManager>()
- if (manager != null) {
- manager.createNotificationChannel(serviceChannel)
- } else {
- logcat { "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
deleted file mode 100644
index 4f5187c4..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt
+++ /dev/null
@@ -1,186 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.settings
-
-import android.content.SharedPreferences
-import androidx.core.content.edit
-import com.github.michaelbull.result.getOrElse
-import com.github.michaelbull.result.runCatching
-import dev.msfjarvis.aps.data.repo.PasswordRepository
-import dev.msfjarvis.aps.injection.context.FilesDirPath
-import dev.msfjarvis.aps.injection.prefs.GitPreferences
-import dev.msfjarvis.aps.injection.prefs.ProxyPreferences
-import dev.msfjarvis.aps.injection.prefs.SettingsPreferences
-import dev.msfjarvis.aps.util.extensions.getString
-import java.io.File
-import javax.inject.Inject
-import javax.inject.Singleton
-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")
- }
- }
-}
-
-@Singleton
-class GitSettings
-@Inject
-constructor(
- @SettingsPreferences private val settings: SharedPreferences,
- @GitPreferences private val encryptedSettings: SharedPreferences,
- @ProxyPreferences private val proxySettings: SharedPreferences,
- @FilesDirPath private val filesDirPath: String,
-) {
-
- private val hostKeyPath = "$filesDirPath/.host_key"
- 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) }
- clearSavedHostKey()
- }
- 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) }
- }
- var rebaseOnPull
- get() = settings.getBoolean(PreferenceKeys.REBASE_ON_PULL, true)
- set(value) {
- settings.edit { putBoolean(PreferenceKeys.REBASE_ON_PULL, 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 && newProtocol != Protocol.Https) &&
- 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
- }
-
- /** Deletes a previously saved SSH host key */
- fun clearSavedHostKey() {
- File(hostKeyPath).delete()
- }
-
- /** Returns true if a host key was previously saved */
- fun hasSavedHostKey(): Boolean = File(hostKeyPath).exists()
-
- companion object {
- private const val DEFAULT_BRANCH = "master"
- }
-}
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
deleted file mode 100644
index 7e91ab41..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt
+++ /dev/null
@@ -1,154 +0,0 @@
-/*
- * Copyright © 2014-2021 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.SharedPreferences
-import androidx.core.content.edit
-import com.github.michaelbull.result.get
-import com.github.michaelbull.result.runCatching
-import dev.msfjarvis.aps.util.extensions.getString
-import dev.msfjarvis.aps.util.git.sshj.SshKey
-import java.io.File
-import java.net.URI
-import logcat.LogPriority.ERROR
-import logcat.LogPriority.INFO
-import logcat.logcat
-
-private const val TAG = "Migrations"
-
-fun runMigrations(filesDirPath: String, sharedPrefs: SharedPreferences, gitSettings: GitSettings) {
- migrateToGitUrlBasedConfig(sharedPrefs, gitSettings)
- migrateToHideAll(sharedPrefs)
- migrateToSshKey(filesDirPath, sharedPrefs)
- migrateToClipboardHistory(sharedPrefs)
- migrateToDiceware(sharedPrefs)
- removeExternalStorageProperties(sharedPrefs)
-}
-
-private fun migrateToGitUrlBasedConfig(sharedPrefs: SharedPreferences, gitSettings: GitSettings) {
- val serverHostname = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_SERVER) ?: return
- logcat(TAG, INFO) { "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
- ) {
- logcat(TAG, ERROR) { "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(filesDirPath: String, sharedPrefs: SharedPreferences) {
- val privateKeyFile = File(filesDirPath, ".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) }
- }
-}
-
-private fun migrateToClipboardHistory(sharedPrefs: SharedPreferences) {
- if (sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X)) {
- sharedPrefs.edit {
- putBoolean(
- PreferenceKeys.CLEAR_CLIPBOARD_HISTORY,
- sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, false)
- )
- remove(PreferenceKeys.CLEAR_CLIPBOARD_20X)
- }
- }
-}
-
-private fun migrateToDiceware(sharedPrefs: SharedPreferences) {
- if (sharedPrefs.contains(PreferenceKeys.PREF_KEY_PWGEN_TYPE)) {
- sharedPrefs.edit {
- if (sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd") {
- putString(PreferenceKeys.PREF_KEY_PWGEN_TYPE, "diceware")
- }
- }
- }
-}
-
-private fun removeExternalStorageProperties(prefs: SharedPreferences) {
- logcat(TAG, INFO) { "Removing preferences related to external storage" }
- prefs.edit {
- if (prefs.contains(PreferenceKeys.GIT_EXTERNAL)) {
- if (prefs.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) {
- putBoolean(PreferenceKeys.GIT_EXTERNAL_MIGRATED, true)
- }
- remove(PreferenceKeys.GIT_EXTERNAL)
- }
- if (prefs.contains(PreferenceKeys.GIT_EXTERNAL_REPO)) {
- remove(PreferenceKeys.GIT_EXTERNAL_REPO)
- }
- }
-}
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
deleted file mode 100644
index 6b48b6a9..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * Copyright © 2014-2021 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
deleted file mode 100644
index 7f4e0daf..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt
+++ /dev/null
@@ -1,88 +0,0 @@
-/*
- * Copyright © 2014-2021 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 AUTOFILL_ENABLE = "autofill_enable"
- const val BIOMETRIC_AUTH = "biometric_auth"
- @Deprecated(
- message = "Use CLEAR_CLIPBOARD_HISTORY instead",
- replaceWith = ReplaceWith("PreferenceKeys.CLEAR_CLIPBOARD_HISTORY"),
- )
- const val CLEAR_CLIPBOARD_20X = "clear_clipboard_20x"
- const val CLEAR_CLIPBOARD_HISTORY = "clear_clipboard_history"
- 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"
- @Deprecated(message = "We're removing support for external storage")
- const val GIT_EXTERNAL = "git_external"
- @Deprecated(message = "We're removing support for external storage")
- const val GIT_EXTERNAL_REPO = "git_external_repo"
- const val GIT_EXTERNAL_MIGRATED = "git_external_migrated"
- 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_PWGEN_TYPE = "pref_key_pwgen_type"
- const val REPOSITORY_INITIALIZED = "repository_initialized"
- const val REPO_CHANGED = "repo_changed"
- const val SEARCH_ON_START = "search_on_start"
-
- @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"
-
- const val REBASE_ON_PULL = "rebase_on_pull"
-
- const val DICEWARE_SEPARATOR = "diceware_separator"
- const val DICEWARE_LENGTH = "diceware_length"
- const val DISABLE_SYNC_ACTION = "disable_sync_action"
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/shortcuts/ShortcutHandler.kt b/app/src/main/java/dev/msfjarvis/aps/util/shortcuts/ShortcutHandler.kt
deleted file mode 100644
index 2ca881ed..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/shortcuts/ShortcutHandler.kt
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.shortcuts
-
-import android.content.Context
-import android.content.Intent
-import android.content.pm.ShortcutInfo
-import android.content.pm.ShortcutManager
-import android.graphics.drawable.Icon
-import android.os.Build
-import androidx.annotation.RequiresApi
-import androidx.core.content.getSystemService
-import dagger.Reusable
-import dagger.hilt.android.qualifiers.ApplicationContext
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.data.password.PasswordItem
-import javax.inject.Inject
-import logcat.logcat
-
-@Reusable
-class ShortcutHandler
-@Inject
-constructor(
- @ApplicationContext val context: Context,
-) {
-
- private companion object {
-
- // The max shortcut count from the system is set to 15 for some godforsaken reason, which
- // makes zero sense and is why our update logic just never worked. Capping it at 4 which is
- // what most launchers seem to have agreed upon is the only reasonable solution.
- private const val MAX_SHORTCUT_COUNT = 4
- }
-
- /**
- * Creates a
- * [dynamic shortcut](https://developer.android.com/guide/topics/ui/shortcuts/creating-shortcuts#dynamic)
- * that shows up with the app icon on long press. The list of items is capped to
- * [MAX_SHORTCUT_COUNT] and older items are removed by a simple LRU sweep.
- */
- fun addDynamicShortcut(item: PasswordItem, intent: Intent) {
- if (Build.VERSION.SDK_INT < 25) return
- val shortcutManager: ShortcutManager = context.getSystemService() ?: return
- val shortcut = buildShortcut(item, intent)
- val shortcuts = shortcutManager.dynamicShortcuts
- // If we're above or equal to the maximum shortcuts allowed, drop the last item.
- if (shortcuts.size >= MAX_SHORTCUT_COUNT) {
- shortcuts.removeLast()
- }
- // Reverse the list so we can append our new shortcut at the 'end'.
- shortcuts.reverse()
- shortcuts.add(shortcut)
- // Reverse it again, so the previous items are now in the correct order and our new item
- // is at the front like it's supposed to.
- shortcuts.reverse()
- // Write back the new shortcuts.
- shortcutManager.dynamicShortcuts = shortcuts.map(::rebuildShortcut)
- }
-
- /**
- * Creates a
- * [pinned shortcut](https://developer.android.com/guide/topics/ui/shortcuts/creating-shortcuts#pinned)
- * which presents a UI to users, allowing manual placement on the launcher screen. This method is
- * a no-op if the user's default launcher does not support pinned shortcuts.
- */
- fun addPinnedShortcut(item: PasswordItem, intent: Intent) {
- if (Build.VERSION.SDK_INT < 26) return
- val shortcutManager: ShortcutManager = context.getSystemService() ?: return
- if (!shortcutManager.isRequestPinShortcutSupported) {
- logcat { "addPinnedShortcut: pin shortcuts unsupported" }
- return
- }
- val shortcut = buildShortcut(item, intent)
- shortcutManager.requestPinShortcut(shortcut, null)
- }
-
- /** Creates a [ShortcutInfo] from [item] and assigns [intent] to it. */
- @RequiresApi(25)
- private fun buildShortcut(item: PasswordItem, intent: Intent): ShortcutInfo {
- return ShortcutInfo.Builder(context, item.fullPathToParent)
- .setShortLabel(item.toString())
- .setLongLabel(item.fullPathToParent + item.toString())
- .setIcon(Icon.createWithResource(context, R.drawable.ic_lock_open_24px))
- .setIntent(intent)
- .build()
- }
-
- /**
- * Takes an existing [ShortcutInfo] and builds a fresh instance of [ShortcutInfo] with the same
- * data, which ensures that the get/set dance in [addDynamicShortcut] does not cause invalidation
- * of icon assets, resulting in invisible icons in all but the newest launcher shortcut.
- */
- @RequiresApi(25)
- private fun rebuildShortcut(shortcut: ShortcutInfo): ShortcutInfo {
- // Non-null assertions are fine since we know these values aren't null.
- return ShortcutInfo.Builder(context, shortcut.id)
- .setShortLabel(shortcut.shortLabel!!)
- .setLongLabel(shortcut.longLabel!!)
- .setIcon(Icon.createWithResource(context, R.drawable.ic_lock_open_24px))
- .setIntent(shortcut.intent!!)
- .build()
- }
-}
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
deleted file mode 100644
index 9c4dc4f4..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * Copyright © 2014-2021 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
-import javax.inject.Inject
-
-/** [Uri] backed TOTP URL parser. */
-class UriTotpFinder @Inject constructor() : TotpFinder {
-
- override fun findSecret(content: String): String? {
- content.split("\n".toRegex()).forEach { line ->
- if (line.startsWith(TotpFinder.TOTP_FIELDS[0])) {
- return Uri.parse(line).getQueryParameter("secret")
- }
- if (line.startsWith(TotpFinder.TOTP_FIELDS[1], ignoreCase = true)) {
- return line.split(": *".toRegex(), 2).toTypedArray()[1]
- }
- }
- return null
- }
-
- override fun findDigits(content: String): String {
- return getQueryParameter(content, "digits") ?: "6"
- }
-
- override fun findPeriod(content: String): Long {
- return getQueryParameter(content, "period")?.toLongOrNull() ?: 30
- }
-
- override fun findAlgorithm(content: String): String {
- return getQueryParameter(content, "algorithm") ?: "sha1"
- }
-
- override fun findIssuer(content: String): String? {
- return getQueryParameter(content, "issuer") ?: Uri.parse(content).authority
- }
-
- private fun getQueryParameter(content: String, parameterName: String): String? {
- content.split("\n".toRegex()).forEach { line ->
- val uri = Uri.parse(line)
- if (
- line.startsWith(TotpFinder.TOTP_FIELDS[0]) && uri.getQueryParameter(parameterName) != null
- ) {
- return uri.getQueryParameter(parameterName)
- }
- }
- return null
- }
-}
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
deleted file mode 100644
index 5f3d04e3..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt
+++ /dev/null
@@ -1,473 +0,0 @@
-/*
- * Copyright © 2014-2021 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 com.github.androidpasswordstore.sublimefuzzy.Fuzzy
-import dev.msfjarvis.aps.data.password.PasswordItem
-import dev.msfjarvis.aps.data.repo.PasswordRepository
-import dev.msfjarvis.aps.util.autofill.AutofillPreferences
-import dev.msfjarvis.aps.util.autofill.DirectoryStructure
-import dev.msfjarvis.aps.util.extensions.sharedPrefs
-import dev.msfjarvis.aps.util.extensions.unsafeLazy
-import dev.msfjarvis.aps.util.settings.PasswordSortOrder
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import java.io.File
-import java.text.Collator
-import java.util.Locale
-import java.util.Stack
-import kotlinx.coroutines.CoroutineScope
-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.launch
-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 {
- val (_, score) = Fuzzy.fuzzyMatch(filter, longName)
- return score
-}
-
-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 unsafeLazy { 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 passwordList =
- when (if (searchAction.filter == "") FilterMode.NoFilter else searchAction.filterMode) {
- 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 !file.name.startsWith(".git")
- }
- if (isDirectory) {
- !isHidden
- } else {
- !isHidden && file.extension == "gpg"
- }
- }
-
- private fun listFiles(dir: File): Flow<File> {
- return dir.listFiles(::shouldTake)?.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(::shouldTake)
- }
-
- 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 coroutineScope: CoroutineScope,
- private val viewHolderBinder: suspend 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 {
- coroutineScope.launch(Dispatchers.Main.immediate) { viewHolderBinder.invoke(holder, 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().uppercase(Locale.getDefault())
- }
-}