aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/build.gradle12
-rw-r--r--app/proguard-rules.pro5
-rw-r--r--app/src/androidTest/java/com/zeapo/pwdstore/utils/UriTotpFinderTest.kt39
-rw-r--r--app/src/main/AndroidManifest.xml8
-rw-r--r--app/src/main/assets/publicsuffixesbin104756 -> 104735 bytes
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/Application.kt32
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/ClipboardService.kt5
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/LaunchActivity.kt5
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/PasswordEntry.kt78
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt4
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt86
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt5
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/UserPreference.kt116
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.kt13
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt9
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt40
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt26
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt24
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt100
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt2
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt3
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt9
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt2
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/crypto/BasePgpActivity.kt18
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/crypto/DecryptActivity.kt51
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/crypto/GetKeyIdsActivity.kt3
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt51
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt19
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/BreakOutOfDetached.kt2
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/CloneOperation.kt2
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.kt5
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt5
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/GitOperation.kt16
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt13
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/PullOperation.kt2
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/PushOperation.kt2
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/ResetToRemoteOperation.kt2
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/SyncOperation.kt2
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java27
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/config/SshjConfig.kt271
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/config/SshjSessionFactory.kt2
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/model/PasswordEntry.kt121
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/pwgen/PasswordGenerator.kt3
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/pwgenxkpwd/XkpwdDictionary.kt5
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/sshkeygen/ShowSshKeyFragment.kt7
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenActivity.kt94
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenFragment.kt111
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/ui/adapters/PasswordItemRecyclerAdapter.kt3
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/ui/dialogs/PasswordGeneratorDialogFragment.kt3
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt3
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/utils/Otp.kt88
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/utils/PasswordRepository.kt13
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt60
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/utils/TotpFinder.kt32
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/utils/UriTotpFinder.kt57
-rw-r--r--app/src/main/res/drawable/ic_qr_code_scanner.xml9
-rw-r--r--app/src/main/res/layout/activity_ssh_keygen.xml (renamed from app/src/main/res/layout/fragment_ssh_keygen.xml)0
-rw-r--r--app/src/main/res/layout/decrypt_layout.xml27
-rw-r--r--app/src/main/res/layout/password_creation_activity.xml13
-rw-r--r--app/src/main/res/layout/password_row_layout.xml3
-rw-r--r--app/src/main/res/values-es/strings.xml1
-rw-r--r--app/src/main/res/values-fr/strings.xml1
-rw-r--r--app/src/main/res/values-ru/strings.xml1
-rw-r--r--app/src/main/res/values/strings.xml7
-rw-r--r--app/src/test/java/com/zeapo/pwdstore/PasswordEntryTest.kt63
-rw-r--r--app/src/test/java/com/zeapo/pwdstore/model/PasswordEntryTest.kt107
-rw-r--r--app/src/test/java/com/zeapo/pwdstore/utils/OtpTest.kt50
67 files changed, 1438 insertions, 560 deletions
diff --git a/app/build.gradle b/app/build.gradle
index 25040ca4..4489c0ba 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -25,8 +25,8 @@ android {
defaultConfig {
applicationId 'dev.msfjarvis.aps'
- versionCode 10920
- versionName '1.9.2'
+ versionCode 10921
+ versionName '1.10.0-SNAPSHOT'
}
lintOptions {
@@ -71,8 +71,9 @@ android {
}
dependencies {
- implementation deps.androidx.annotation
implementation deps.androidx.activity_ktx
+ implementation deps.androidx.annotation
+ implementation deps.androidx.autofill
implementation deps.androidx.appcompat
implementation deps.androidx.biometric
implementation deps.androidx.constraint_layout
@@ -92,6 +93,10 @@ dependencies {
implementation deps.kotlin.coroutines.android
implementation deps.kotlin.coroutines.core
+ implementation deps.first_party.openpgp_ktx
+ implementation deps.first_party.zxing_android_embedded
+
+ implementation deps.third_party.commons_codec
implementation deps.third_party.fastscroll
implementation(deps.third_party.jgit) {
exclude group: 'org.apache.httpcomponents', module: 'httpclient'
@@ -100,7 +105,6 @@ dependencies {
implementation deps.third_party.sshj
implementation deps.third_party.bouncycastle
implementation deps.third_party.plumber
- implementation deps.third_party.openpgp_ktx
implementation deps.third_party.ssh_auth
implementation deps.third_party.timber
implementation deps.third_party.timberkt
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 232f70e7..8e53f558 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -31,3 +31,8 @@
# Tink (for security-crypto)
# I'm most certainly not a fan of this catch-all rule
-keep class com.google.crypto.tink.proto.** { *; }
+
+# WhatTheStack
+-keep class com.haroldadmin.whatthestack.WhatTheStackInitializer {
+ <init>();
+}
diff --git a/app/src/androidTest/java/com/zeapo/pwdstore/utils/UriTotpFinderTest.kt b/app/src/androidTest/java/com/zeapo/pwdstore/utils/UriTotpFinderTest.kt
new file mode 100644
index 00000000..3397ed0d
--- /dev/null
+++ b/app/src/androidTest/java/com/zeapo/pwdstore/utils/UriTotpFinderTest.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package com.zeapo.pwdstore.utils
+
+import org.junit.Test
+import kotlin.test.assertEquals
+
+class UriTotpFinderTest {
+
+ private val totpFinder = UriTotpFinder()
+
+ @Test
+ fun findSecret() {
+ assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(TOTP_URI))
+ assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret("name\npassword\ntotp: HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ"))
+ }
+
+ @Test
+ fun findDigits() {
+ assertEquals("12", totpFinder.findDigits(TOTP_URI))
+ }
+
+ @Test
+ fun findPeriod() {
+ assertEquals(25, totpFinder.findPeriod(TOTP_URI))
+ }
+
+ @Test
+ fun findAlgorithm() {
+ assertEquals("SHA256", totpFinder.findAlgorithm(TOTP_URI))
+ }
+
+ companion object {
+ const val TOTP_URI = "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA256&digits=12&period=25"
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b0c3193d..2098abc9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -46,6 +46,14 @@
</activity>
<activity
+ android:name="com.journeyapps.barcodescanner.CaptureActivity"
+ android:clearTaskOnLaunch="true"
+ android:stateNotNeeded="true"
+ android:theme="@style/zxing_CaptureTheme"
+ android:windowSoftInputMode="stateAlwaysHidden"
+ tools:node="replace" />
+
+ <activity
android:name=".git.GitOperationActivity"
android:theme="@style/NoBackgroundTheme" />
diff --git a/app/src/main/assets/publicsuffixes b/app/src/main/assets/publicsuffixes
index 028c098e..5e420823 100644
--- a/app/src/main/assets/publicsuffixes
+++ b/app/src/main/assets/publicsuffixes
Binary files differ
diff --git a/app/src/main/java/com/zeapo/pwdstore/Application.kt b/app/src/main/java/com/zeapo/pwdstore/Application.kt
index b0c4eec0..3ccf37fe 100644
--- a/app/src/main/java/com/zeapo/pwdstore/Application.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/Application.kt
@@ -13,8 +13,8 @@ import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES
import androidx.preference.PreferenceManager
import com.github.ajalt.timberkt.Timber.DebugTree
import com.github.ajalt.timberkt.Timber.plant
-import org.bouncycastle.jce.provider.BouncyCastleProvider
-import java.security.Security
+import com.zeapo.pwdstore.git.config.setUpBouncyCastleForSshj
+import com.zeapo.pwdstore.utils.PreferenceKeys
@Suppress("Unused")
class Application : android.app.Application(), SharedPreferences.OnSharedPreferenceChangeListener {
@@ -24,12 +24,13 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere
override fun onCreate() {
super.onCreate()
prefs = PreferenceManager.getDefaultSharedPreferences(this)
- if (BuildConfig.ENABLE_DEBUG_FEATURES || prefs?.getBoolean("enable_debug_logging", false) == true) {
+ if (BuildConfig.ENABLE_DEBUG_FEATURES || prefs?.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false) ==
+ true) {
plant(DebugTree())
}
prefs?.registerOnSharedPreferenceChangeListener(this)
setNightMode()
- setUpBouncyCastle()
+ setUpBouncyCastleForSshj()
}
override fun onTerminate() {
@@ -38,32 +39,13 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere
}
override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String) {
- if (key == "app_theme") {
+ if (key == PreferenceKeys.APP_THEME) {
setNightMode()
}
}
- private fun setUpBouncyCastle() {
- // Replace the Android BC provider with the Java BouncyCastle provider since the former does
- // not include all the required algorithms.
- // TODO: Verify that we are indeed using the fast Android-native implementation whenever
- // possible.
- // 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)
- Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1)
- }
- }
-
private fun setNightMode() {
- AppCompatDelegate.setDefaultNightMode(when (prefs?.getString("app_theme", getString(R.string.app_theme_def))) {
+ 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
diff --git a/app/src/main/java/com/zeapo/pwdstore/ClipboardService.kt b/app/src/main/java/com/zeapo/pwdstore/ClipboardService.kt
index ceb84020..ef0cc459 100644
--- a/app/src/main/java/com/zeapo/pwdstore/ClipboardService.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/ClipboardService.kt
@@ -17,6 +17,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.content.getSystemService
import androidx.preference.PreferenceManager
import com.github.ajalt.timberkt.d
+import com.zeapo.pwdstore.utils.PreferenceKeys
import com.zeapo.pwdstore.utils.clipboard
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -44,7 +45,7 @@ class ClipboardService : Service() {
ACTION_START -> {
val time = try {
- Integer.parseInt(settings.getString("general_show_time", "45") as String)
+ Integer.parseInt(settings.getString(PreferenceKeys.GENERAL_SHOW_TIME, "45") as String)
} catch (e: NumberFormatException) {
45
}
@@ -82,7 +83,7 @@ class ClipboardService : Service() {
}
private fun clearClipboard() {
- val deepClear = settings.getBoolean("clear_clipboard_20x", false)
+ val deepClear = settings.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, false)
val clipboard = clipboard
if (clipboard != null) {
diff --git a/app/src/main/java/com/zeapo/pwdstore/LaunchActivity.kt b/app/src/main/java/com/zeapo/pwdstore/LaunchActivity.kt
index b452f521..e143657e 100644
--- a/app/src/main/java/com/zeapo/pwdstore/LaunchActivity.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/LaunchActivity.kt
@@ -12,20 +12,21 @@ import androidx.core.content.edit
import androidx.preference.PreferenceManager
import com.zeapo.pwdstore.crypto.DecryptActivity
import com.zeapo.pwdstore.utils.BiometricAuthenticator
+import com.zeapo.pwdstore.utils.PreferenceKeys
class LaunchActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
- if (prefs.getBoolean("biometric_auth", false)) {
+ if (prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH, false)) {
BiometricAuthenticator.authenticate(this) {
when (it) {
is BiometricAuthenticator.Result.Success -> {
startTargetActivity(false)
}
is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> {
- prefs.edit { remove("biometric_auth") }
+ prefs.edit { remove(PreferenceKeys.BIOMETRIC_AUTH) }
startTargetActivity(false)
}
is BiometricAuthenticator.Result.Failure, BiometricAuthenticator.Result.Cancelled -> {
diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordEntry.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordEntry.kt
deleted file mode 100644
index d9168d39..00000000
--- a/app/src/main/java/com/zeapo/pwdstore/PasswordEntry.kt
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package com.zeapo.pwdstore
-
-import java.io.ByteArrayOutputStream
-import java.io.UnsupportedEncodingException
-
-/**
- * A single entry in password store.
- */
-class PasswordEntry(content: String) {
-
- val password: String
- val username: String?
- var extraContent: String
- private set
-
- @Throws(UnsupportedEncodingException::class)
- constructor(os: ByteArrayOutputStream) : this(os.toString("UTF-8"))
-
- init {
- val passContent = content.split("\n".toRegex(), 2).toTypedArray()
- password = passContent[0]
- extraContent = findExtraContent(passContent)
- username = findUsername()
- }
-
- fun hasExtraContent(): Boolean {
- return extraContent.isNotEmpty()
- }
-
- fun hasUsername(): Boolean {
- return username != null
- }
-
- val extraContentWithoutUsername by lazy {
- var usernameFound = false
- extraContent.splitToSequence("\n").filter { line ->
- if (usernameFound)
- return@filter true
- if (USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) }) {
- usernameFound = true
- return@filter false
- }
- true
- }.joinToString(separator = "\n")
- }
-
- private fun findUsername(): String? {
- extraContent.splitToSequence("\n").forEach { line ->
- for (prefix in USERNAME_FIELDS) {
- if (line.startsWith(prefix, ignoreCase = true))
- return line.substring(prefix.length).trimStart()
- }
- }
- return null
- }
-
- private fun findExtraContent(passContent: Array<String>): String {
- return if (passContent.size > 1) passContent[1] else ""
- }
-
- companion object {
- val USERNAME_FIELDS = arrayOf(
- "login:",
- "username:",
- "user:",
- "account:",
- "email:",
- "name:",
- "handle:",
- "id:",
- "identity:"
- )
- }
-}
diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt
index d811f341..ca53b320 100644
--- a/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt
@@ -31,6 +31,7 @@ import com.zeapo.pwdstore.ui.adapters.PasswordItemRecyclerAdapter
import com.zeapo.pwdstore.ui.dialogs.ItemCreationBottomSheet
import com.zeapo.pwdstore.utils.PasswordItem
import com.zeapo.pwdstore.utils.PasswordRepository
+import com.zeapo.pwdstore.utils.PreferenceKeys
import com.zeapo.pwdstore.utils.viewBinding
import me.zhanghai.android.fastscroll.FastScrollerBuilder
import java.io.File
@@ -78,7 +79,8 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
} else {
// When authentication is set to ConnectionMode.None then the only git operation we
// can run is a pull, so automatically fallback to that.
- val operationId = when (ConnectionMode.fromString(settings.getString("git_remote_auth", null))) {
+ val operationId = when (ConnectionMode.fromString(settings.getString
+ (PreferenceKeys.GIT_REMOTE_AUTH, null))) {
ConnectionMode.None -> BaseGitActivity.REQUEST_PULL
else -> BaseGitActivity.REQUEST_SYNC
}
diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt
index 0a768fd0..ee258b80 100644
--- a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt
@@ -65,6 +65,7 @@ import com.zeapo.pwdstore.utils.PasswordRepository.Companion.getRepositoryDirect
import com.zeapo.pwdstore.utils.PasswordRepository.Companion.initialize
import com.zeapo.pwdstore.utils.PasswordRepository.Companion.isInitialized
import com.zeapo.pwdstore.utils.PasswordRepository.PasswordSortOrder.Companion.getSortOrder
+import com.zeapo.pwdstore.utils.PreferenceKeys
import com.zeapo.pwdstore.utils.commitChange
import com.zeapo.pwdstore.utils.isInsideRepository
import com.zeapo.pwdstore.utils.listFilesRecursively
@@ -121,7 +122,7 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) {
// If user opens app with permission granted then revokes and returns,
// prevent attempt to create password list fragment
var savedInstance = savedInstanceState
- if (savedInstanceState != null && (!settings.getBoolean("git_external", false) ||
+ if (savedInstanceState != null && (!settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) ||
ContextCompat.checkSelfPermission(
activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED)) {
@@ -179,15 +180,20 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) {
}
}
+ public override fun onStart() {
+ super.onStart()
+ refreshPasswordList()
+ }
+
public override fun onResume() {
super.onResume()
// do not attempt to checkLocalRepository() if no storage permission: immediate crash
- if (settings.getBoolean("git_external", false)) {
+ if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) {
hasRequiredStoragePermissions(true)
} else {
checkLocalRepository()
}
- if (settings.getBoolean("search_on_start", false) && ::searchItem.isInitialized) {
+ if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false) && ::searchItem.isInitialized) {
if (!searchItem.isActionViewExpanded) {
searchItem.expandActionView()
}
@@ -206,7 +212,7 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) {
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
val menuRes = when {
- ConnectionMode.fromString(settings.getString("git_remote_auth", null))
+ ConnectionMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH, null))
== ConnectionMode.None -> R.menu.main_menu_no_auth
PasswordRepository.isGitRepo() -> R.menu.main_menu_git
else -> R.menu.main_menu_non_git
@@ -256,7 +262,7 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) {
return true
}
})
- if (settings.getBoolean("search_on_start", false)) {
+ if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false)) {
searchItem.expandActionView()
}
return super.onPrepareOptionsMenu(menu)
@@ -341,7 +347,7 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) {
check(localDir.mkdir()) { "Failed to create directory!" }
createRepository(localDir)
if (File(localDir.absolutePath + "/.gpg-id").createNewFile()) {
- settings.edit { putBoolean("repository_initialized", true) }
+ settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }
} else {
throw IllegalStateException("Failed to initialize repository state.")
}
@@ -356,8 +362,8 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) {
}
private fun initializeRepositoryInfo() {
- val externalRepo = settings.getBoolean("git_external", false)
- val externalRepoPath = settings.getString("git_external_repo", null)
+ val externalRepo = settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)
+ val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO, null)
if (externalRepo && !hasRequiredStoragePermissions()) {
return
}
@@ -370,7 +376,7 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) {
return // if not empty, just show me the passwords!
}
}
- val keyIds = settings.getStringSet("openpgp_key_ids_set", HashSet())
+ val keyIds = settings.getStringSet(PreferenceKeys.OPENPGP_KEY_IDS_SET, HashSet())
if (keyIds != null && keyIds.isEmpty()) {
MaterialAlertDialogBuilder(this)
.setMessage(resources.getString(R.string.key_dialog_text))
@@ -426,12 +432,12 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) {
}
private fun checkLocalRepository(localDir: File?) {
- if (localDir != null && settings.getBoolean("repository_initialized", false)) {
+ if (localDir != null && settings.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) {
d { "Check, dir: ${localDir.absolutePath}" }
// do not push the fragment if we already have it
if (supportFragmentManager.findFragmentByTag("PasswordsList") == null ||
- settings.getBoolean("repo_changed", false)) {
- settings.edit { putBoolean("repo_changed", false) }
+ settings.getBoolean(PreferenceKeys.REPO_CHANGED, false)) {
+ settings.edit { putBoolean(PreferenceKeys.REPO_CHANGED, false) }
plist = PasswordFragment()
val args = Bundle()
args.putString(REQUEST_ARG_PATH, getRepositoryDirectory(applicationContext).absolutePath)
@@ -530,7 +536,7 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) {
.show()
return false
}
- if (settings.getStringSet("openpgp_key_ids_set", HashSet()).isNullOrEmpty()) {
+ if (settings.getStringSet(PreferenceKeys.OPENPGP_KEY_IDS_SET, HashSet()).isNullOrEmpty()) {
MaterialAlertDialogBuilder(this)
.setTitle(resources.getString(R.string.no_key_selected_dialog_title))
.setMessage(resources.getString(R.string.no_key_selected_dialog_text))
@@ -584,7 +590,6 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) {
item.file.toRelativeString(getRepositoryDirectory(this))
}
))
- refreshPasswordList()
}
.setNegativeButton(resources.getString(R.string.dialog_no), null)
.show()
@@ -662,7 +667,7 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) {
}
}
}
- resetPasswordList()
+ refreshPasswordList()
plist?.dismissActionMode()
}.launch(intent)
}
@@ -727,24 +732,17 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) {
}
/**
- * Resets navigation to the repository root and refreshes the password list accordingly.
- *
- * Use this rather than [refreshPasswordList] after major file system operations that may remove
- * the current directory and thus require a full reset of the navigation stack.
- */
- fun resetPasswordList() {
- model.reset()
- supportActionBar!!.setDisplayHomeAsUpEnabled(false)
- }
-
- /**
- * Refreshes the password list by re-executing the last navigation or search action.
- *
- * Use this rather than [resetPasswordList] after file system operations limited to the current
- * folder since it preserves the scroll position and navigation stack.
+ * 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.
*/
fun refreshPasswordList() {
- model.forceRefresh()
+ if (model.currentDir.value?.isDirectory == true) {
+ model.forceRefresh()
+ } else {
+ model.reset()
+ supportActionBar!!.setDisplayHomeAsUpEnabled(false)
+ }
}
private val currentDir: File
@@ -754,7 +752,7 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) {
if (resultCode == Activity.RESULT_OK) {
when (requestCode) {
// if we get here with a RESULT_OK then it's probably OK :)
- BaseGitActivity.REQUEST_CLONE -> settings.edit { putBoolean("repository_initialized", true) }
+ BaseGitActivity.REQUEST_CLONE -> settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }
// if went from decrypt->edit and user saved changes, we need to commitChange
REQUEST_CODE_DECRYPT_AND_VERIFY -> {
if (data != null && data.getBooleanExtra("needCommit", false)) {
@@ -763,21 +761,19 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) {
data.extras!!.getString("LONG_NAME")))
}
}
- refreshPasswordList()
}
REQUEST_CODE_ENCRYPT -> {
commitChange(resources.getString(R.string.git_commit_add_text,
data!!.extras!!.getString("LONG_NAME")))
- refreshPasswordList()
}
BaseGitActivity.REQUEST_INIT, NEW_REPO_BUTTON -> initializeRepositoryInfo()
- BaseGitActivity.REQUEST_SYNC, BaseGitActivity.REQUEST_PULL -> resetPasswordList()
+ BaseGitActivity.REQUEST_SYNC, BaseGitActivity.REQUEST_PULL -> refreshPasswordList()
HOME -> checkLocalRepository()
// duplicate code
CLONE_REPO_BUTTON -> {
- if (settings.getBoolean("git_external", false) &&
- settings.getString("git_external_repo", null) != null) {
- val externalRepoPath = settings.getString("git_external_repo", null)
+ if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) &&
+ settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO, null) != null) {
+ val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO, null)
val dir = externalRepoPath?.let { File(it) }
if (dir != null &&
dir.exists() &&
@@ -793,6 +789,14 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) {
intent.putExtra(BaseGitActivity.REQUEST_ARG_OP, BaseGitActivity.REQUEST_CLONE)
startActivityForResult(intent, BaseGitActivity.REQUEST_CLONE)
}
+ else -> {
+ d { "Unexpected request code: $requestCode" }
+ // FIXME: The sync operation returns with a requestCode of 65535 instead of the
+ // expected 105. It is completely unclear why, but the issue might be resolved
+ // by switching to ActivityResultContracts. For now, we run the post-sync code
+ // also when encountering an unexpected request code.
+ refreshPasswordList()
+ }
}
}
super.onActivityResult(requestCode, resultCode, data)
@@ -829,7 +833,7 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) {
.setTitle(resources.getString(R.string.location_dialog_title))
.setMessage(resources.getString(R.string.location_dialog_text))
.setPositiveButton(resources.getString(R.string.location_hidden)) { _, _ ->
- settings.edit { putBoolean("git_external", false) }
+ settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, false) }
when (operation) {
NEW_REPO_BUTTON -> initializeRepositoryInfo()
CLONE_REPO_BUTTON -> {
@@ -840,8 +844,8 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) {
}
}
.setNegativeButton(resources.getString(R.string.location_sdcard)) { _, _ ->
- settings.edit { putBoolean("git_external", true) }
- val externalRepo = settings.getString("git_external_repo", null)
+ settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, true) }
+ val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO, null)
if (externalRepo == null) {
val intent = Intent(activity, UserPreference::class.java)
intent.putExtra("operation", "git_external")
diff --git a/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt b/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt
index 05ccf28f..860676e4 100644
--- a/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt
@@ -30,6 +30,7 @@ import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences
import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure
import com.zeapo.pwdstore.utils.PasswordItem
import com.zeapo.pwdstore.utils.PasswordRepository
+import com.zeapo.pwdstore.utils.PreferenceKeys
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
@@ -140,9 +141,9 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
get() = PasswordRepository.getRepositoryDirectory(getApplication())
private val settings = PreferenceManager.getDefaultSharedPreferences(getApplication())
private val showHiddenDirs
- get() = settings.getBoolean("show_hidden_folders", false)
+ get() = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false)
private val defaultSearchMode
- get() = if (settings.getBoolean("filter_recursively", true)) {
+ get() = if (settings.getBoolean(PreferenceKeys.FILTER_RECURSIVELY, true)) {
SearchMode.RecursivelyInSubdirectories
} else {
SearchMode.InCurrentDirectoryOnly
diff --git a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt
index fb27ba6a..03e314f6 100644
--- a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt
@@ -52,6 +52,7 @@ import com.zeapo.pwdstore.sshkeygen.ShowSshKeyFragment
import com.zeapo.pwdstore.sshkeygen.SshKeyGenActivity
import com.zeapo.pwdstore.utils.BiometricAuthenticator
import com.zeapo.pwdstore.utils.PasswordRepository
+import com.zeapo.pwdstore.utils.PreferenceKeys
import com.zeapo.pwdstore.utils.autofillManager
import com.zeapo.pwdstore.utils.getEncryptedPrefs
import me.msfjarvis.openpgpktx.util.OpenPgpUtils
@@ -88,16 +89,16 @@ class UserPreference : AppCompatActivity() {
addPreferencesFromResource(R.xml.preference)
// Git preferences
- val gitServerPreference = findPreference<Preference>("git_server_info")
- val openkeystoreIdPreference = findPreference<Preference>("ssh_openkeystore_clear_keyid")
- val gitConfigPreference = findPreference<Preference>("git_config")
- val sshKeyPreference = findPreference<Preference>("ssh_key")
- val sshKeygenPreference = findPreference<Preference>("ssh_keygen")
- clearSavedPassPreference = findPreference("clear_saved_pass")
- val viewSshKeyPreference = findPreference<Preference>("ssh_see_key")
- val deleteRepoPreference = findPreference<Preference>("git_delete_repo")
- val externalGitRepositoryPreference = findPreference<Preference>("git_external")
- val selectExternalGitRepositoryPreference = findPreference<Preference>("pref_select_external")
+ val gitServerPreference = findPreference<Preference>(PreferenceKeys.GIT_SERVER_INFO)
+ val openkeystoreIdPreference = findPreference<Preference>(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID)
+ val gitConfigPreference = findPreference<Preference>(PreferenceKeys.GIT_CONFIG)
+ val sshKeyPreference = findPreference<Preference>(PreferenceKeys.SSH_KEY)
+ val sshKeygenPreference = findPreference<Preference>(PreferenceKeys.SSH_KEYGEN)
+ clearSavedPassPreference = findPreference(PreferenceKeys.CLEAR_SAVED_PASS)
+ val viewSshKeyPreference = findPreference<Preference>(PreferenceKeys.SSH_SEE_KEY)
+ val deleteRepoPreference = findPreference<Preference>(PreferenceKeys.GIT_DELETE_REPO)
+ val externalGitRepositoryPreference = findPreference<Preference>(PreferenceKeys.GIT_EXTERNAL)
+ val selectExternalGitRepositoryPreference = findPreference<Preference>(PreferenceKeys.PREF_SELECT_EXTERNAL)
if (!PasswordRepository.isGitRepo()) {
listOfNotNull(
@@ -109,21 +110,21 @@ class UserPreference : AppCompatActivity() {
}
// Crypto preferences
- val keyPreference = findPreference<Preference>("openpgp_key_id_pref")
+ val keyPreference = findPreference<Preference>(PreferenceKeys.OPENPGP_KEY_ID_PREF)
// General preferences
- val showTimePreference = findPreference<Preference>("general_show_time")
- val clearClipboard20xPreference = findPreference<CheckBoxPreference>("clear_clipboard_20x")
+ val showTimePreference = findPreference<Preference>(PreferenceKeys.GENERAL_SHOW_TIME)
+ val clearClipboard20xPreference = findPreference<CheckBoxPreference>(PreferenceKeys.CLEAR_CLIPBOARD_20X)
// Autofill preferences
- autoFillEnablePreference = findPreference("autofill_enable")
- val oreoAutofillDirectoryStructurePreference = findPreference<ListPreference>("oreo_autofill_directory_structure")
- val oreoAutofillDefaultUsername = findPreference<EditTextPreference>("oreo_autofill_default_username")
- val oreoAutofillCustomPublixSuffixes = findPreference<EditTextPreference>("oreo_autofill_custom_public_suffixes")
- val autoFillAppsPreference = findPreference<Preference>("autofill_apps")
- val autoFillDefaultPreference = findPreference<CheckBoxPreference>("autofill_default")
- val autoFillAlwaysShowDialogPreference = findPreference<CheckBoxPreference>("autofill_always")
- val autoFillShowFullNamePreference = findPreference<CheckBoxPreference>("autofill_full_path")
+ autoFillEnablePreference = findPreference(PreferenceKeys.AUTOFILL_ENABLE)
+ val oreoAutofillDirectoryStructurePreference = findPreference<ListPreference>(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE)
+ val oreoAutofillDefaultUsername = findPreference<EditTextPreference>(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME)
+ val oreoAutofillCustomPublixSuffixes = findPreference<EditTextPreference>(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES)
+ val autoFillAppsPreference = findPreference<Preference>(PreferenceKeys.AUTOFILL_APPS)
+ val autoFillDefaultPreference = findPreference<CheckBoxPreference>(PreferenceKeys.AUTOFILL_DEFAULT)
+ val autoFillAlwaysShowDialogPreference = findPreference<CheckBoxPreference>(PreferenceKeys.AUTOFILL_ALWAYS)
+ val autoFillShowFullNamePreference = findPreference<CheckBoxPreference>(PreferenceKeys.AUTOFILL_FULL_PATH)
autofillDependencies = listOfNotNull(
autoFillAppsPreference,
autoFillDefaultPreference,
@@ -143,13 +144,13 @@ class UserPreference : AppCompatActivity() {
}
// Misc preferences
- val appVersionPreference = findPreference<Preference>("app_version")
+ val appVersionPreference = findPreference<Preference>(PreferenceKeys.APP_VERSION)
- selectExternalGitRepositoryPreference?.summary = sharedPreferences.getString("git_external_repo", getString(R.string.no_repo_selected))
- viewSshKeyPreference?.isVisible = sharedPreferences.getBoolean("use_generated_key", false)
- deleteRepoPreference?.isVisible = !sharedPreferences.getBoolean("git_external", false)
- clearClipboard20xPreference?.isVisible = sharedPreferences.getString("general_show_time", "45")?.toInt() != 0
- openkeystoreIdPreference?.isVisible = sharedPreferences.getString("ssh_openkeystore_keyid", null)?.isNotEmpty()
+ selectExternalGitRepositoryPreference?.summary = sharedPreferences.getString(PreferenceKeys.GIT_EXTERNAL_REPO, getString(R.string.no_repo_selected))
+ viewSshKeyPreference?.isVisible = sharedPreferences.getBoolean(PreferenceKeys.USE_GENERATED_KEY, false)
+ deleteRepoPreference?.isVisible = !sharedPreferences.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)
+ clearClipboard20xPreference?.isVisible = sharedPreferences.getString(PreferenceKeys.GENERAL_SHOW_TIME, "45")?.toInt() != 0
+ openkeystoreIdPreference?.isVisible = sharedPreferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null)?.isNotEmpty()
?: false
updateAutofillSettings()
@@ -160,7 +161,7 @@ class UserPreference : AppCompatActivity() {
keyPreference?.let { pref ->
updateKeyIDsSummary(pref)
pref.onPreferenceClickListener = ClickListener {
- val providerPackageName = requireNotNull(sharedPreferences.getString("openpgp_provider_list", ""))
+ val providerPackageName = requireNotNull(sharedPreferences.getString(PreferenceKeys.OPENPGP_PROVIDER_LIST, ""))
if (providerPackageName.isEmpty()) {
Snackbar.make(requireView(), resources.getString(R.string.provider_toast_text), Snackbar.LENGTH_LONG).show()
false
@@ -193,17 +194,17 @@ class UserPreference : AppCompatActivity() {
clearSavedPassPreference?.onPreferenceClickListener = ClickListener {
encryptedPreferences.edit {
- if (encryptedPreferences.getString("https_password", null) != null)
- remove("https_password")
- else if (encryptedPreferences.getString("ssh_key_local_passphrase", null) != null)
- remove("ssh_key_local_passphrase")
+ if (encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD, null) != null)
+ remove(PreferenceKeys.HTTPS_PASSWORD)
+ else if (encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE, null) != null)
+ remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE)
}
updateClearSavedPassphrasePrefs()
true
}
openkeystoreIdPreference?.onPreferenceClickListener = ClickListener {
- sharedPreferences.edit { putString("ssh_openkeystore_keyid", null) }
+ sharedPreferences.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) }
it.isVisible = false
true
}
@@ -237,7 +238,7 @@ class UserPreference : AppCompatActivity() {
removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
}
}
- sharedPreferences.edit { putBoolean("repository_initialized", false) }
+ sharedPreferences.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) }
dialogInterface.cancel()
callingActivity.finish()
}
@@ -248,7 +249,7 @@ class UserPreference : AppCompatActivity() {
}
selectExternalGitRepositoryPreference?.summary =
- sharedPreferences.getString("git_external_repo", context.getString(R.string.no_repo_selected))
+ sharedPreferences.getString(PreferenceKeys.GIT_EXTERNAL_REPO, context.getString(R.string.no_repo_selected))
selectExternalGitRepositoryPreference?.onPreferenceClickListener = ClickListener {
callingActivity.selectExternalGitRepository()
true
@@ -257,7 +258,7 @@ class UserPreference : AppCompatActivity() {
val resetRepo = Preference.OnPreferenceChangeListener { _, o ->
deleteRepoPreference?.isVisible = !(o as Boolean)
PasswordRepository.closeRepository()
- sharedPreferences.edit { putBoolean("repo_changed", true) }
+ sharedPreferences.edit { putBoolean(PreferenceKeys.REPO_CHANGED, true) }
true
}
@@ -275,8 +276,8 @@ class UserPreference : AppCompatActivity() {
true
}
- findPreference<Preference>("export_passwords")?.apply {
- isVisible = sharedPreferences.getBoolean("repository_initialized", false)
+ findPreference<Preference>(PreferenceKeys.EXPORT_PASSWORDS)?.apply {
+ isVisible = sharedPreferences.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)
onPreferenceClickListener = Preference.OnPreferenceClickListener {
callingActivity.exportPasswords()
true
@@ -294,12 +295,13 @@ class UserPreference : AppCompatActivity() {
}
showTimePreference?.summaryProvider = Preference.SummaryProvider<Preference> {
- getString(R.string.pref_clipboard_timeout_summary, sharedPreferences.getString("general_show_time", "45"))
+ getString(R.string.pref_clipboard_timeout_summary, sharedPreferences.getString
+ (PreferenceKeys.GENERAL_SHOW_TIME, "45"))
}
- findPreference<CheckBoxPreference>("enable_debug_logging")?.isVisible = !BuildConfig.ENABLE_DEBUG_FEATURES
+ findPreference<CheckBoxPreference>(PreferenceKeys.ENABLE_DEBUG_LOGGING)?.isVisible = !BuildConfig.ENABLE_DEBUG_FEATURES
- findPreference<CheckBoxPreference>("biometric_auth")?.apply {
+ findPreference<CheckBoxPreference>(PreferenceKeys.BIOMETRIC_AUTH)?.apply {
val isFingerprintSupported = BiometricManager.from(requireContext()).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS
if (!isFingerprintSupported) {
isEnabled = false
@@ -314,13 +316,13 @@ class UserPreference : AppCompatActivity() {
when (result) {
is BiometricAuthenticator.Result.Success -> {
// Apply the changes
- putBoolean("biometric_auth", checked)
+ putBoolean(PreferenceKeys.BIOMETRIC_AUTH, checked)
isEnabled = true
}
else -> {
// If any error occurs, revert back to the previous state. This
// catch-all clause includes the cancellation case.
- putBoolean("biometric_auth", !checked)
+ putBoolean(PreferenceKeys.BIOMETRIC_AUTH, !checked)
isChecked = !checked
isEnabled = true
}
@@ -337,20 +339,20 @@ class UserPreference : AppCompatActivity() {
}
}
- val prefCustomXkpwdDictionary = findPreference<Preference>("pref_key_custom_dict")
+ val prefCustomXkpwdDictionary = findPreference<Preference>(PreferenceKeys.PREF_KEY_CUSTOM_DICT)
prefCustomXkpwdDictionary?.onPreferenceClickListener = ClickListener {
callingActivity.storeCustomDictionaryPath()
true
}
- val dictUri = sharedPreferences.getString("pref_key_custom_dict", "")
+ val dictUri = sharedPreferences.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT, "")
if (!TextUtils.isEmpty(dictUri)) {
setCustomDictSummary(prefCustomXkpwdDictionary, Uri.parse(dictUri))
}
- val prefIsCustomDict = findPreference<CheckBoxPreference>("pref_key_is_custom_dict")
- val prefCustomDictPicker = findPreference<Preference>("pref_key_custom_dict")
- val prefPwgenType = findPreference<ListPreference>("pref_key_pwgen_type")
+ val prefIsCustomDict = findPreference<CheckBoxPreference>(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT)
+ val prefCustomDictPicker = findPreference<Preference>(PreferenceKeys.PREF_KEY_CUSTOM_DICT)
+ val prefPwgenType = findPreference<ListPreference>(PreferenceKeys.PREF_KEY_PWGEN_TYPE)
updateXkPasswdPrefsVisibility(prefPwgenType?.value, prefIsCustomDict, prefCustomDictPicker)
prefPwgenType?.onPreferenceChangeListener = ChangeListener { _, newValue ->
@@ -371,7 +373,7 @@ class UserPreference : AppCompatActivity() {
}
private fun updateKeyIDsSummary(preference: Preference) {
- val selectedKeys = (sharedPreferences.getStringSet("openpgp_key_ids_set", null)
+ val selectedKeys = (sharedPreferences.getStringSet(PreferenceKeys.OPENPGP_KEY_IDS_SET, null)
?: HashSet()).toTypedArray()
preference.summary = if (selectedKeys.isEmpty()) {
resources.getString(R.string.pref_no_key_selected)
@@ -410,8 +412,8 @@ class UserPreference : AppCompatActivity() {
private fun updateClearSavedPassphrasePrefs() {
clearSavedPassPreference?.apply {
- val sshPass = encryptedPreferences.getString("ssh_key_local_passphrase", null)
- val httpsPass = encryptedPreferences.getString("https_password", null)
+ val sshPass = encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE, null)
+ val httpsPass = encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD, null)
if (sshPass == null && httpsPass == null) {
isVisible = false
return@apply
@@ -654,8 +656,8 @@ class UserPreference : AppCompatActivity() {
).show()
val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
- prefs.edit { putBoolean("use_generated_key", false) }
- getEncryptedPrefs("git_operation").edit { remove("ssh_key_local_passphrase") }
+ prefs.edit { putBoolean(PreferenceKeys.USE_GENERATED_KEY, false) }
+ getEncryptedPrefs("git_operation").edit { remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) }
// Delete the public key from generation
File("""$filesDir/.ssh_key.pub""").delete()
@@ -688,12 +690,12 @@ class UserPreference : AppCompatActivity() {
.setTitle(getString(R.string.sdcard_root_warning_title))
.setMessage(getString(R.string.sdcard_root_warning_message))
.setPositiveButton("Remove everything") { _, _ ->
- prefs.edit { putString("git_external_repo", uri?.path) }
+ prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, uri?.path) }
}
.setNegativeButton(R.string.dialog_cancel, null)
.show()
}
- prefs.edit { putString("git_external_repo", repoPath) }
+ prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, repoPath) }
}
EXPORT_PASSWORDS -> {
val uri = data.data
@@ -716,9 +718,9 @@ class UserPreference : AppCompatActivity() {
).show()
val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
- prefs.edit { putString("pref_key_custom_dict", uri.toString()) }
+ prefs.edit { putString(PreferenceKeys.PREF_KEY_CUSTOM_DICT, uri.toString()) }
- val customDictPref = prefsFragment.findPreference<Preference>("pref_key_custom_dict")
+ val customDictPref = prefsFragment.findPreference<Preference>(PreferenceKeys.PREF_KEY_CUSTOM_DICT)
setCustomDictSummary(customDictPref, uri)
// copy user selected file to internal storage
val inputStream = contentResolver.openInputStream(uri)
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.kt
index 26c86fc8..177233a8 100644
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.kt
@@ -28,9 +28,10 @@ import com.github.ajalt.timberkt.Timber.tag
import com.github.ajalt.timberkt.e
import com.github.ajalt.timberkt.i
import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.zeapo.pwdstore.PasswordEntry
import com.zeapo.pwdstore.R
+import com.zeapo.pwdstore.model.PasswordEntry
import com.zeapo.pwdstore.utils.PasswordRepository
+import com.zeapo.pwdstore.utils.PreferenceKeys
import com.zeapo.pwdstore.utils.splitLines
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -228,7 +229,7 @@ class AutofillService : AccessibilityService(), CoroutineScope by CoroutineScope
// if autofill_always checked, show dialog even if no matches (automatic
// or otherwise)
- if (items.isEmpty() && !settings!!.getBoolean("autofill_always", false)) {
+ if (items.isEmpty() && !settings!!.getBoolean(PreferenceKeys.AUTOFILL_ALWAYS, false)) {
return
}
showSelectPasswordDialog(packageName, appName, isWeb)
@@ -268,7 +269,7 @@ class AutofillService : AccessibilityService(), CoroutineScope by CoroutineScope
var settingsURL = webViewURL
// if autofill_default is checked and prefs.getString DNE, 'Automatically match with password'/"first" otherwise "never"
- val defValue = if (settings!!.getBoolean("autofill_default", true)) "/first" else "/never"
+ val defValue = if (settings!!.getBoolean(PreferenceKeys.AUTOFILL_DEFAULT, true)) "/first" else "/never"
val prefs: SharedPreferences = getSharedPreferences("autofill_web", Context.MODE_PRIVATE)
var preference: String
@@ -305,7 +306,7 @@ class AutofillService : AccessibilityService(), CoroutineScope by CoroutineScope
private fun setAppMatchingPasswords(appName: String, packageName: String) {
// if autofill_default is checked and prefs.getString DNE, 'Automatically match with password'/"first" otherwise "never"
- val defValue = if (settings!!.getBoolean("autofill_default", true)) "/first" else "/never"
+ val defValue = if (settings!!.getBoolean(PreferenceKeys.AUTOFILL_DEFAULT, true)) "/first" else "/never"
val prefs: SharedPreferences = getSharedPreferences("autofill", Context.MODE_PRIVATE)
val preference: String?
@@ -414,7 +415,7 @@ class AutofillService : AccessibilityService(), CoroutineScope by CoroutineScope
// make it optional (or make height a setting for the same effect)
val itemNames = arrayOfNulls<CharSequence>(items.size + 2)
val passwordDirectory = PasswordRepository.getRepositoryDirectory(applicationContext).toString()
- val autofillFullPath = settings!!.getBoolean("autofill_full_path", false)
+ val autofillFullPath = settings!!.getBoolean(PreferenceKeys.AUTOFILL_FULL_PATH, false)
for (i in items.indices) {
if (autofillFullPath) {
itemNames[i] = items[i].path.replace(".gpg", "")
@@ -518,7 +519,7 @@ class AutofillService : AccessibilityService(), CoroutineScope by CoroutineScope
// save password entry for pasting the username as well
if (entry?.hasUsername() == true) {
lastPassword = entry
- val ttl = Integer.parseInt(settings!!.getString("general_show_time", "45")!!)
+ val ttl = Integer.parseInt(settings!!.getString(PreferenceKeys.GENERAL_SHOW_TIME, "45")!!)
withContext(Dispatchers.Main) { Toast.makeText(applicationContext, getString(R.string.autofill_toast_username, ttl), Toast.LENGTH_LONG).show() }
lastPasswordMaxDate = System.currentTimeMillis() + ttl * 1000L
}
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt
index 101f96a0..838b7a05 100644
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt
@@ -19,9 +19,10 @@ import androidx.annotation.RequiresApi
import androidx.preference.PreferenceManager
import com.github.ajalt.timberkt.Timber.tag
import com.github.ajalt.timberkt.e
-import com.zeapo.pwdstore.PasswordEntry
import com.zeapo.pwdstore.R
+import com.zeapo.pwdstore.model.PasswordEntry
import com.zeapo.pwdstore.utils.PasswordRepository
+import com.zeapo.pwdstore.utils.PreferenceKeys
import java.io.File
import java.security.MessageDigest
@@ -39,7 +40,7 @@ private fun ByteArray.base64(): String {
private fun Context.getDefaultUsername(): String? {
return PreferenceManager
.getDefaultSharedPreferences(this)
- .getString("oreo_autofill_default_username", null)
+ .getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME, null)
}
private fun stableHash(array: Collection<ByteArray>): String {
@@ -86,7 +87,7 @@ val AssistStructure.ViewNode.webOrigin: String?
"$scheme://$domain"
}
-data class Credentials(val username: String?, val password: String) {
+data class Credentials(val username: String?, val password: String, val otp: String?) {
companion object {
fun fromStoreEntry(
context: Context,
@@ -98,7 +99,7 @@ data class Credentials(val username: String?, val password: String) {
val username = entry.username
?: directoryStructure.getUsernameFor(file)
?: context.getDefaultUsername()
- return Credentials(username, entry.password)
+ return Credentials(username, entry.password, entry.calculateTotpCode())
}
}
}
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt
index f1514851..8e209a60 100644
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt
@@ -29,6 +29,7 @@ sealed class AutofillScenario<out T : Any> {
companion object {
const val BUNDLE_KEY_USERNAME_ID = "usernameId"
const val BUNDLE_KEY_FILL_USERNAME = "fillUsername"
+ const val BUNDLE_KEY_OTP_ID = "otpId"
const val BUNDLE_KEY_CURRENT_PASSWORD_IDS = "currentPasswordIds"
const val BUNDLE_KEY_NEW_PASSWORD_IDS = "newPasswordIds"
const val BUNDLE_KEY_GENERIC_PASSWORD_IDS = "genericPasswordIds"
@@ -38,6 +39,7 @@ sealed class AutofillScenario<out T : Any> {
Builder<AutofillId>().apply {
username = clientState.getParcelable(BUNDLE_KEY_USERNAME_ID)
fillUsername = clientState.getBoolean(BUNDLE_KEY_FILL_USERNAME)
+ otp = clientState.getParcelable(BUNDLE_KEY_OTP_ID)
currentPassword.addAll(
clientState.getParcelableArrayList(
BUNDLE_KEY_CURRENT_PASSWORD_IDS
@@ -64,6 +66,7 @@ sealed class AutofillScenario<out T : Any> {
class Builder<T : Any> {
var username: T? = null
var fillUsername = false
+ var otp: T? = null
val currentPassword = mutableListOf<T>()
val newPassword = mutableListOf<T>()
val genericPassword = mutableListOf<T>()
@@ -74,6 +77,7 @@ sealed class AutofillScenario<out T : Any> {
ClassifiedAutofillScenario(
username = username,
fillUsername = fillUsername,
+ otp = otp,
currentPassword = currentPassword,
newPassword = newPassword
)
@@ -81,6 +85,7 @@ sealed class AutofillScenario<out T : Any> {
GenericAutofillScenario(
username = username,
fillUsername = fillUsername,
+ otp = otp,
genericPassword = genericPassword
)
}
@@ -89,6 +94,7 @@ sealed class AutofillScenario<out T : Any> {
abstract val username: T?
abstract val fillUsername: Boolean
+ abstract val otp: T?
abstract val allPasswordFields: List<T>
abstract val passwordFieldsToFillOnMatch: List<T>
abstract val passwordFieldsToFillOnSearch: List<T>
@@ -99,19 +105,19 @@ sealed class AutofillScenario<out T : Any> {
get() = listOfNotNull(username) + passwordFieldsToSave
val allFields
- get() = listOfNotNull(username) + allPasswordFields
+ get() = listOfNotNull(username, otp) + allPasswordFields
fun fieldsToFillOn(action: AutofillAction): List<T> {
- val passwordFieldsToFill = when (action) {
- AutofillAction.Match -> passwordFieldsToFillOnMatch
- AutofillAction.Search -> passwordFieldsToFillOnSearch
+ val credentialFieldsToFill = when (action) {
+ AutofillAction.Match -> passwordFieldsToFillOnMatch + listOfNotNull(otp)
+ AutofillAction.Search -> passwordFieldsToFillOnSearch + listOfNotNull(otp)
AutofillAction.Generate -> passwordFieldsToFillOnGenerate
}
return when {
- passwordFieldsToFill.isNotEmpty() -> {
+ credentialFieldsToFill.isNotEmpty() -> {
// If the current action would fill into any password field, we also fill into the
// username field if possible.
- listOfNotNull(username.takeIf { fillUsername }) + passwordFieldsToFill
+ listOfNotNull(username.takeIf { fillUsername }) + credentialFieldsToFill
}
allPasswordFields.isEmpty() && action != AutofillAction.Generate -> {
// If there no password fields at all, we still offer to fill the username, e.g. in
@@ -127,6 +133,7 @@ sealed class AutofillScenario<out T : Any> {
data class ClassifiedAutofillScenario<T : Any>(
override val username: T?,
override val fillUsername: Boolean,
+ override val otp: T?,
val currentPassword: List<T>,
val newPassword: List<T>
) : AutofillScenario<T>() {
@@ -147,6 +154,7 @@ data class ClassifiedAutofillScenario<T : Any>(
data class GenericAutofillScenario<T : Any>(
override val username: T?,
override val fillUsername: Boolean,
+ override val otp: T?,
val genericPassword: List<T>
) : AutofillScenario<T>() {
@@ -183,14 +191,15 @@ fun Dataset.Builder.fillWith(
) {
val credentialsToFill = credentials ?: Credentials(
"USERNAME",
- "PASSWORD"
+ "PASSWORD",
+ "OTP"
)
for (field in scenario.fieldsToFillOn(action)) {
- val value = if (field == scenario.username) {
- credentialsToFill.username
- } else {
- credentialsToFill.password
- } ?: continue
+ val value = when (field) {
+ scenario.username -> credentialsToFill.username
+ scenario.otp -> credentialsToFill.otp
+ else -> credentialsToFill.password
+ }
setValue(field, AutofillValue.forText(value))
}
}
@@ -209,6 +218,7 @@ inline fun <T : Any, S : Any> AutofillScenario<T>.map(transform: (T) -> S): Auto
val builder = AutofillScenario.Builder<S>()
builder.username = username?.let(transform)
builder.fillUsername = fillUsername
+ builder.otp = otp?.let(transform)
when (this) {
is ClassifiedAutofillScenario -> {
builder.currentPassword.addAll(currentPassword.map(transform))
@@ -225,9 +235,10 @@ inline fun <T : Any, S : Any> AutofillScenario<T>.map(transform: (T) -> S): Auto
@JvmName("toBundleAutofillId")
private fun AutofillScenario<AutofillId>.toBundle(): Bundle = when (this) {
is ClassifiedAutofillScenario<AutofillId> -> {
- Bundle(4).apply {
+ Bundle(5).apply {
putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username)
putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername)
+ putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp)
putParcelableArrayList(
AutofillScenario.BUNDLE_KEY_CURRENT_PASSWORD_IDS, ArrayList(currentPassword)
)
@@ -237,9 +248,10 @@ private fun AutofillScenario<AutofillId>.toBundle(): Bundle = when (this) {
}
}
is GenericAutofillScenario<AutofillId> -> {
- Bundle(3).apply {
+ Bundle(4).apply {
putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username)
putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername)
+ putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp)
putParcelableArrayList(
AutofillScenario.BUNDLE_KEY_GENERIC_PASSWORD_IDS, ArrayList(genericPassword)
)
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt
index 6f3b4ff5..90bb7051 100644
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt
@@ -30,18 +30,23 @@ val autofillStrategy = strategy {
// TODO: Introduce a custom fill/generate/update flow for this scenario
rule {
newPassword {
- takePair { all { hasAutocompleteHintNewPassword } }
+ takePair { all { hasHintNewPassword } }
breakTieOnPair { any { isFocused } }
}
currentPassword(optional = true) {
takeSingle { alreadyMatched ->
val adjacentToNewPasswords =
directlyPrecedes(alreadyMatched) || directlyFollows(alreadyMatched)
- hasAutocompleteHintCurrentPassword && adjacentToNewPasswords
+ // The Autofill framework has not hint that applies to current passwords only.
+ // In this scenario, we have already matched fields a pair of fields with a specific
+ // new password hint, so we take a generic Autofill password hint to mean a current
+ // password.
+ (hasAutocompleteHintCurrentPassword || hasAutofillHintPassword) &&
+ adjacentToNewPasswords
}
}
username(optional = true) {
- takeSingle { hasAutocompleteHintUsername }
+ takeSingle { hasHintUsername }
breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
breakTieOnSingle { isFocused }
}
@@ -73,7 +78,7 @@ val autofillStrategy = strategy {
breakTieOnSingle { isFocused }
}
username(optional = true) {
- takeSingle { hasAutocompleteHintUsername }
+ takeSingle { hasHintUsername }
breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
breakTieOnSingle { isFocused }
}
@@ -115,7 +120,7 @@ val autofillStrategy = strategy {
// field.
rule(applyInSingleOriginMode = true) {
newPassword {
- takeSingle { hasAutocompleteHintNewPassword && isFocused }
+ takeSingle { hasHintNewPassword && isFocused }
}
username(optional = true) {
takeSingle { alreadyMatched ->
@@ -157,7 +162,7 @@ val autofillStrategy = strategy {
// filling of hidden password fields to scenarios where this is clearly warranted.
rule {
username {
- takeSingle { hasAutocompleteHintUsername && isFocused }
+ takeSingle { hasHintUsername && isFocused }
}
currentPassword(matchHidden = true) {
takeSingle { alreadyMatched ->
@@ -166,12 +171,19 @@ val autofillStrategy = strategy {
}
}
+ // Match a single focused OTP field.
+ rule(applyInSingleOriginMode = true) {
+ otp {
+ takeSingle { otpCertainty >= Likely && isFocused }
+ }
+ }
+
// Match a single focused username field without a password field.
rule(applyInSingleOriginMode = true) {
username {
takeSingle { usernameCertainty >= Likely && isFocused }
breakTieOnSingle { usernameCertainty >= Certain }
- breakTieOnSingle { hasAutocompleteHintUsername }
+ breakTieOnSingle { hasHintUsername }
}
}
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt
index 3b648234..5e6f460e 100644
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt
@@ -164,7 +164,7 @@ class AutofillRule private constructor(
)
enum class FillableFieldType {
- Username, CurrentPassword, NewPassword, GenericPassword,
+ Username, Otp, CurrentPassword, NewPassword, GenericPassword,
}
@AutofillDsl
@@ -192,6 +192,18 @@ class AutofillRule private constructor(
)
}
+ fun otp(optional: Boolean = false, block: SingleFieldMatcher.Builder.() -> Unit) {
+ require(matchers.none { it.type == FillableFieldType.Otp }) { "Every rule block can only have at most one otp block" }
+ matchers.add(
+ AutofillRuleMatcher(
+ type = FillableFieldType.Otp,
+ matcher = SingleFieldMatcher.Builder().apply(block).build(),
+ optional = optional,
+ matchHidden = false
+ )
+ )
+ }
+
fun currentPassword(optional: Boolean = false, matchHidden: Boolean = false, block: FieldMatcher.Builder.() -> Unit) {
require(matchers.none { it.type == FillableFieldType.GenericPassword }) { "Every rule block can only have either genericPassword or {current,new}Password blocks" }
matchers.add(
@@ -247,6 +259,7 @@ class AutofillRule private constructor(
fun match(
allPassword: List<FormField>,
allUsername: List<FormField>,
+ allOtp: List<FormField>,
singleOriginMode: Boolean,
isManualRequest: Boolean
): AutofillScenario<FormField>? {
@@ -264,6 +277,7 @@ class AutofillRule private constructor(
for ((type, matcher, optional, matchHidden) in matchers) {
val fieldsToMatchOn = when (type) {
FillableFieldType.Username -> allUsername
+ FillableFieldType.Otp -> allOtp
else -> allPassword
}.filter { matchHidden || it.isVisible }
val matchResult = matcher.match(fieldsToMatchOn, alreadyMatched) ?: if (optional) {
@@ -281,6 +295,10 @@ class AutofillRule private constructor(
// Hidden username fields should be saved but not filled.
scenarioBuilder.fillUsername = scenarioBuilder.username!!.isVisible == true
}
+ FillableFieldType.Otp -> {
+ check(matchResult.size == 1 && scenarioBuilder.otp == null)
+ scenarioBuilder.otp = matchResult.single()
+ }
FillableFieldType.CurrentPassword -> scenarioBuilder.currentPassword.addAll(
matchResult
)
@@ -338,12 +356,16 @@ class AutofillStrategy private constructor(private val rules: List<AutofillRule>
val possibleUsernameFields =
fields.filter { it.usernameCertainty >= CertaintyLevel.Possible }
d { "Possible username fields: ${possibleUsernameFields.size}" }
+ val possibleOtpFields =
+ fields.filter { it.otpCertainty >= CertaintyLevel.Possible }
+ d { "Possible otp fields: ${possibleOtpFields.size}" }
// Return the result of the first rule that matches
d { "Rules: ${rules.size}" }
for (rule in rules) {
return rule.match(
possiblePasswordFields,
possibleUsernameFields,
+ possibleOtpFields,
singleOriginMode = singleOriginMode,
isManualRequest = isManualRequest
)
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt
index 0c96b587..2b18bbb6 100644
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt
@@ -10,6 +10,7 @@ import android.text.InputType
import android.view.View
import android.view.autofill.AutofillId
import androidx.annotation.RequiresApi
+import androidx.autofill.HintConstants
import java.util.Locale
enum class CertaintyLevel {
@@ -30,15 +31,30 @@ class FormField(
companion object {
- @RequiresApi(Build.VERSION_CODES.O)
- private val HINTS_USERNAME = listOf(View.AUTOFILL_HINT_USERNAME)
+ private val HINTS_USERNAME = listOf(
+ HintConstants.AUTOFILL_HINT_USERNAME,
+ HintConstants.AUTOFILL_HINT_NEW_USERNAME
+ )
- @RequiresApi(Build.VERSION_CODES.O)
- private val HINTS_PASSWORD = listOf(View.AUTOFILL_HINT_PASSWORD)
+ private val HINTS_NEW_PASSWORD = listOf(
+ HintConstants.AUTOFILL_HINT_NEW_PASSWORD
+ )
- @RequiresApi(Build.VERSION_CODES.O)
- private val HINTS_FILLABLE = HINTS_USERNAME + HINTS_PASSWORD + listOf(
- View.AUTOFILL_HINT_EMAIL_ADDRESS, View.AUTOFILL_HINT_NAME, View.AUTOFILL_HINT_PHONE
+ private val HINTS_PASSWORD = HINTS_NEW_PASSWORD + listOf(
+ HintConstants.AUTOFILL_HINT_PASSWORD
+ )
+
+ private val HINTS_OTP = listOf(
+ HintConstants.AUTOFILL_HINT_SMS_OTP
+ )
+
+ @Suppress("DEPRECATION")
+ private val HINTS_FILLABLE = HINTS_USERNAME + HINTS_PASSWORD + HINTS_OTP + listOf(
+ HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS,
+ HintConstants.AUTOFILL_HINT_NAME,
+ HintConstants.AUTOFILL_HINT_PERSON_NAME,
+ HintConstants.AUTOFILL_HINT_PHONE,
+ HintConstants.AUTOFILL_HINT_PHONE_NUMBER
)
private val ANDROID_TEXT_FIELD_CLASS_NAMES = listOf(
@@ -67,17 +83,20 @@ class FormField(
private val HTML_INPUT_FIELD_TYPES_USERNAME = listOf("email", "tel", "text")
private val HTML_INPUT_FIELD_TYPES_PASSWORD = listOf("password")
+ private val HTML_INPUT_FIELD_TYPES_OTP = listOf("tel", "text")
private val HTML_INPUT_FIELD_TYPES_FILLABLE =
- HTML_INPUT_FIELD_TYPES_USERNAME + HTML_INPUT_FIELD_TYPES_PASSWORD
+ (HTML_INPUT_FIELD_TYPES_USERNAME + HTML_INPUT_FIELD_TYPES_PASSWORD + HTML_INPUT_FIELD_TYPES_OTP).toSet().toList()
@RequiresApi(Build.VERSION_CODES.O)
- private fun isSupportedHint(hint: String) = hint in HINTS_USERNAME + HINTS_PASSWORD
+ private fun isSupportedHint(hint: String) = hint in HINTS_FILLABLE
private val EXCLUDED_TERMS = listOf(
"url_bar", // Chrome/Edge/Firefox address bar
"url_field", // Opera address bar
"location_bar_edit_text", // Samsung address bar
- "search", "find", "captcha"
+ "search", "find", "captcha",
+ "postal" // Prevent postal code fields from being mistaken for OTP fields
+
)
private val PASSWORD_HEURISTIC_TERMS = listOf(
"pass", "pswd", "pwd"
@@ -85,8 +104,19 @@ class FormField(
private val USERNAME_HEURISTIC_TERMS = listOf(
"alias", "e-mail", "email", "login", "user"
)
+ private val OTP_HEURISTIC_TERMS = listOf(
+ "einmal", "otp"
+ )
+ private val OTP_WEAK_HEURISTIC_TERMS = listOf(
+ "code"
+ )
}
+ private val List<String>.anyMatchesFieldInfo
+ get() = any {
+ fieldId.contains(it) || hint.contains(it) || htmlName.contains(it)
+ }
+
val autofillId: AutofillId = node.autofillId!!
// Information for heuristics and exclusion rules based only on the current field
@@ -120,6 +150,7 @@ class FormField(
htmlAttributes.entries.joinToString { "${it.key}=${it.value}" }
private val htmlInputType = htmlAttributes["type"]
private val htmlName = htmlAttributes["name"] ?: ""
+ private val htmlMaxLength = htmlAttributes["maxlength"]?.toIntOrNull()
private val isHtmlField = htmlTag == "input"
private val isHtmlPasswordField =
isHtmlField && htmlInputType in HTML_INPUT_FIELD_TYPES_PASSWORD
@@ -138,19 +169,28 @@ class FormField(
private val autofillHints = node.autofillHints?.filter { isSupportedHint(it) } ?: emptyList()
private val excludedByAutofillHints =
if (autofillHints.isEmpty()) false else autofillHints.intersect(HINTS_FILLABLE).isEmpty()
- private val hasAutofillHintPassword = autofillHints.intersect(HINTS_PASSWORD).isNotEmpty()
+ val hasAutofillHintPassword = autofillHints.intersect(HINTS_PASSWORD).isNotEmpty()
+ private val hasAutofillHintNewPassword = autofillHints.intersect(HINTS_NEW_PASSWORD).isNotEmpty()
private val hasAutofillHintUsername = autofillHints.intersect(HINTS_USERNAME).isNotEmpty()
+ private val hasAutofillHintOtp = autofillHints.intersect(HINTS_OTP).isNotEmpty()
// W3C autocomplete hint detection for HTML fields
private val htmlAutocomplete = htmlAttributes["autocomplete"]
// Ignored for now, see excludedByHints
private val excludedByAutocompleteHint = htmlAutocomplete == "off"
- val hasAutocompleteHintUsername = htmlAutocomplete == "username"
+ private val hasAutocompleteHintUsername = htmlAutocomplete == "username"
val hasAutocompleteHintCurrentPassword = htmlAutocomplete == "current-password"
- val hasAutocompleteHintNewPassword = htmlAutocomplete == "new-password"
+ private val hasAutocompleteHintNewPassword = htmlAutocomplete == "new-password"
private val hasAutocompleteHintPassword =
hasAutocompleteHintCurrentPassword || hasAutocompleteHintNewPassword
+ private val hasAutocompleteHintOtp = htmlAutocomplete == "one-time-code"
+
+ // Results of hint-based field type detection
+ val hasHintUsername = hasAutofillHintUsername || hasAutocompleteHintUsername
+ val hasHintPassword = hasAutofillHintPassword || hasAutocompleteHintPassword
+ val hasHintNewPassword = hasAutofillHintNewPassword || hasAutocompleteHintNewPassword
+ val hasHintOtp = hasAutofillHintOtp || hasAutocompleteHintOtp
// Basic autofill exclusion checks
private val hasAutofillTypeText = node.autofillType == View.AUTOFILL_TYPE_TEXT
@@ -176,30 +216,34 @@ class FormField(
val relevantField = isTextField && hasAutofillTypeText && !excludedByHints
- // Exclude fields based on hint and resource ID
+ // Exclude fields based on hint, resource ID or HTML name.
// Note: We still report excluded fields as relevant since they count for adjacency heuristics,
// but ensure that they are never detected as password or username fields.
- private val hasExcludedTerm = EXCLUDED_TERMS.any { fieldId.contains(it) || hint.contains(it) }
+ private val hasExcludedTerm = EXCLUDED_TERMS.anyMatchesFieldInfo
private val notExcluded = relevantField && !hasExcludedTerm
// Password field heuristics (based only on the current field)
private val isPossiblePasswordField =
notExcluded && (isAndroidPasswordField || isHtmlPasswordField)
- private val isCertainPasswordField =
- isPossiblePasswordField && (isHtmlPasswordField || hasAutofillHintPassword || hasAutocompleteHintPassword)
- private val isLikelyPasswordField = isPossiblePasswordField && (isCertainPasswordField || (PASSWORD_HEURISTIC_TERMS.any {
- fieldId.contains(it) || hint.contains(it) || htmlName.contains(it)
- }))
+ private val isCertainPasswordField = isPossiblePasswordField && hasHintPassword
+ private val isLikelyPasswordField = isPossiblePasswordField &&
+ (isCertainPasswordField || PASSWORD_HEURISTIC_TERMS.anyMatchesFieldInfo)
val passwordCertainty =
if (isCertainPasswordField) CertaintyLevel.Certain else if (isLikelyPasswordField) CertaintyLevel.Likely else if (isPossiblePasswordField) CertaintyLevel.Possible else CertaintyLevel.Impossible
+ // OTP field heuristics (based only on the current field)
+ private val isPossibleOtpField = notExcluded && !isPossiblePasswordField && isTextField
+ private val isCertainOtpField = isPossibleOtpField && hasHintOtp
+ private val isLikelyOtpField = isPossibleOtpField && (
+ isCertainOtpField || OTP_HEURISTIC_TERMS.anyMatchesFieldInfo ||
+ ((htmlMaxLength == null || htmlMaxLength in 6..8) && OTP_WEAK_HEURISTIC_TERMS.anyMatchesFieldInfo))
+ val otpCertainty =
+ if (isCertainOtpField) CertaintyLevel.Certain else if (isLikelyOtpField) CertaintyLevel.Likely else if (isPossibleOtpField) CertaintyLevel.Possible else CertaintyLevel.Impossible
+
// Username field heuristics (based only on the current field)
- private val isPossibleUsernameField = notExcluded && !isPossiblePasswordField
- private val isCertainUsernameField =
- isPossibleUsernameField && (hasAutofillHintUsername || hasAutocompleteHintUsername)
- private val isLikelyUsernameField = isPossibleUsernameField && (isCertainUsernameField || (USERNAME_HEURISTIC_TERMS.any {
- fieldId.contains(it) || hint.contains(it) || htmlName.contains(it)
- }))
+ private val isPossibleUsernameField = notExcluded && !isPossiblePasswordField && !isCertainOtpField && isTextField
+ private val isCertainUsernameField = isPossibleUsernameField && hasHintUsername
+ private val isLikelyUsernameField = isPossibleUsernameField && (isCertainUsernameField || (USERNAME_HEURISTIC_TERMS.anyMatchesFieldInfo))
val usernameCertainty =
if (isCertainUsernameField) CertaintyLevel.Certain else if (isLikelyUsernameField) CertaintyLevel.Likely else if (isPossibleUsernameField) CertaintyLevel.Possible else CertaintyLevel.Impossible
@@ -224,8 +268,8 @@ class FormField(
override fun toString(): String {
val field = if (isHtmlTextField) "$htmlTag[type=$htmlInputType]" else className
val description =
- "\"$hint\", \"$fieldId\"${if (isFocused) ", focused" else ""}${if (isVisible) ", visible" else ""}, $webOrigin, $htmlAttributesDebug"
- return "$field ($description): password=$passwordCertainty, username=$usernameCertainty"
+ "\"$hint\", \"$fieldId\"${if (isFocused) ", focused" else ""}${if (isVisible) ", visible" else ""}, $webOrigin, $htmlAttributesDebug, $autofillHints"
+ return "$field ($description): password=$passwordCertainty, username=$usernameCertainty, otp=$otpCertainty"
}
override fun equals(other: Any?): Boolean {
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt
index 350d187b..cdf9a8ff 100644
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt
@@ -106,7 +106,7 @@ class OreoAutofillService : AutofillService() {
callback.onSuccess(
AutofillSaveActivity.makeSaveIntentSender(
this,
- credentials = Credentials(username, password),
+ credentials = Credentials(username, password, null),
formOrigin = formOrigin
)
)
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt
index f1ce6bce..349f0a1b 100644
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt
@@ -7,6 +7,7 @@ package com.zeapo.pwdstore.autofill.oreo
import android.content.Context
import android.util.Patterns
import androidx.preference.PreferenceManager
+import com.zeapo.pwdstore.utils.PreferenceKeys
import kotlinx.coroutines.runBlocking
import mozilla.components.lib.publicsuffixlist.PublicSuffixList
@@ -67,7 +68,7 @@ fun getSuffixPlusUpToOne(domain: String, suffix: String): String? {
fun getCustomSuffixes(context: Context): Sequence<String> {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
- return prefs.getString("oreo_autofill_custom_public_suffixes", "")!!
+ return prefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES, "")!!
.splitToSequence('\n')
.filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' }
}
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt
index 4c806dff..d7d8daaf 100644
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt
@@ -16,12 +16,12 @@ import android.widget.Toast
import androidx.annotation.RequiresApi
import com.github.ajalt.timberkt.d
import com.github.ajalt.timberkt.e
-import com.zeapo.pwdstore.PasswordEntry
import com.zeapo.pwdstore.autofill.oreo.AutofillAction
import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences
import com.zeapo.pwdstore.autofill.oreo.Credentials
import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure
import com.zeapo.pwdstore.autofill.oreo.FillableForm
+import com.zeapo.pwdstore.model.PasswordEntry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -100,7 +100,7 @@ class AutofillDecryptActivity : Activity(), CoroutineScope {
directoryStructure = AutofillPreferences.directoryStructure(this)
d { action.toString() }
launch {
- val credentials = decryptUsernameAndPassword(File(filePath))
+ val credentials = decryptCredential(File(filePath))
if (credentials == null) {
setResult(RESULT_CANCELED)
} else {
@@ -153,7 +153,7 @@ class AutofillDecryptActivity : Activity(), CoroutineScope {
}
}
- private suspend fun decryptUsernameAndPassword(
+ private suspend fun decryptCredential(
file: File,
resumeIntent: Intent? = null
): Credentials? {
@@ -178,6 +178,7 @@ class AutofillDecryptActivity : Activity(), CoroutineScope {
OpenPgpApi.RESULT_CODE_SUCCESS -> {
try {
val entry = withContext(Dispatchers.IO) {
+ @Suppress("BlockingMethodInNonBlockingContext")
PasswordEntry(decryptedOutput)
}
Credentials.fromStoreEntry(this, file, entry, directoryStructure)
@@ -203,7 +204,7 @@ class AutofillDecryptActivity : Activity(), CoroutineScope {
)
}
}
- decryptUsernameAndPassword(file, intentToResume)
+ decryptCredential(file, intentToResume)
} catch (e: Exception) {
e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed with user interaction" }
null
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt
index fad13ec8..b5bd9e38 100644
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt
@@ -130,7 +130,7 @@ class AutofillSaveActivity : Activity() {
finish()
return
}
- val credentials = Credentials(username, password)
+ val credentials = Credentials(username, password, null)
val fillInDataset = FillableForm.makeFillInDataset(
this,
credentials,
diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/BasePgpActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/BasePgpActivity.kt
index 5206a15f..88ba20f1 100644
--- a/app/src/main/java/com/zeapo/pwdstore/crypto/BasePgpActivity.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/crypto/BasePgpActivity.kt
@@ -17,6 +17,7 @@ import android.view.WindowManager
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.CallSuper
+import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.PreferenceManager
import com.github.ajalt.timberkt.Timber.tag
@@ -26,6 +27,7 @@ import com.google.android.material.snackbar.Snackbar
import com.zeapo.pwdstore.ClipboardService
import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.UserPreference
+import com.zeapo.pwdstore.utils.PreferenceKeys
import com.zeapo.pwdstore.utils.clipboard
import com.zeapo.pwdstore.utils.snackbar
import me.msfjarvis.openpgpktx.util.OpenPgpApi
@@ -93,7 +95,7 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
tag(TAG)
- keyIDs = settings.getStringSet("openpgp_key_ids_set", null) ?: emptySet()
+ keyIDs = settings.getStringSet(PreferenceKeys.OPENPGP_KEY_IDS_SET, null) ?: emptySet()
}
/**
@@ -132,7 +134,7 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
* [startActivityForResult].
*/
fun bindToOpenKeychain(onBoundListener: OpenPgpServiceConnection.OnBound, activityResult: ActivityResultLauncher<Intent>) {
- val providerPackageName = settings.getString("openpgp_provider_list", "")
+ val providerPackageName = settings.getString(PreferenceKeys.OPENPGP_PROVIDER_LIST, "")
if (providerPackageName.isNullOrEmpty()) {
Toast.makeText(this, resources.getString(R.string.provider_toast_text), Toast.LENGTH_LONG).show()
activityResult.launch(Intent(this, UserPreference::class.java))
@@ -163,6 +165,7 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
return DateUtils.getRelativeTimeSpanString(this, timeStamp, true)
}
+
/**
* Base handling of OpenKeychain errors based on the error contained in [result]. Subclasses
* can use this when they want to default to sane error handling.
@@ -190,12 +193,16 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
* 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) {
+ 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(R.string.clipboard_copied_text))
+ snackbar(message = resources.getString(snackbarTextRes))
}
}
@@ -209,7 +216,8 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
var clearAfter = 45
try {
- clearAfter = (settings.getString("general_show_time", "45") ?: "45").toInt()
+ clearAfter = (settings.getString(PreferenceKeys.GENERAL_SHOW_TIME, "45")
+ ?: "45").toInt()
} catch (_: NumberFormatException) {
}
diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/DecryptActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/DecryptActivity.kt
index b7d7adcd..3c31a518 100644
--- a/app/src/main/java/com/zeapo/pwdstore/crypto/DecryptActivity.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/crypto/DecryptActivity.kt
@@ -17,17 +17,24 @@ import androidx.activity.result.contract.ActivityResultContracts.StartActivityFo
import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult
import androidx.lifecycle.lifecycleScope
import com.github.ajalt.timberkt.e
-import com.zeapo.pwdstore.PasswordEntry
import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.databinding.DecryptLayoutBinding
+import com.zeapo.pwdstore.model.PasswordEntry
+import com.zeapo.pwdstore.utils.Otp
+import com.zeapo.pwdstore.utils.PreferenceKeys
import com.zeapo.pwdstore.utils.viewBinding
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import me.msfjarvis.openpgpktx.util.OpenPgpApi
import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
import org.openintents.openpgp.IOpenPgpService2
import java.io.ByteArrayOutputStream
import java.io.File
+import java.util.Date
+import kotlin.time.ExperimentalTime
+import kotlin.time.seconds
class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
private val binding by viewBinding(DecryptLayoutBinding::inflate)
@@ -125,6 +132,7 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
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, openKeychainResult)
@@ -141,8 +149,8 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
OpenPgpApi.RESULT_CODE_SUCCESS -> {
try {
- val showPassword = settings.getBoolean("show_password", true)
- val showExtraContent = settings.getBoolean("show_extra_content", true)
+ val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
+ val showExtraContent = settings.getBoolean(PreferenceKeys.SHOW_EXTRA_CONTENT, true)
val monoTypeface = Typeface.createFromAsset(assets, "fonts/sourcecodepro.ttf")
val entry = PasswordEntry(outputStream)
@@ -163,14 +171,16 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
}
if (entry.hasExtraContent()) {
- extraContentContainer.visibility = View.VISIBLE
- extraContent.typeface = monoTypeface
- extraContent.setText(entry.extraContentWithoutUsername)
- if (!showExtraContent) {
- extraContent.transformationMethod = PasswordTransformationMethod.getInstance()
+ if (entry.extraContentWithoutAuthData.isNotEmpty()) {
+ extraContentContainer.visibility = View.VISIBLE
+ extraContent.typeface = monoTypeface
+ extraContent.setText(entry.extraContentWithoutAuthData)
+ if (!showExtraContent) {
+ extraContent.transformationMethod = PasswordTransformationMethod.getInstance()
+ }
+ extraContentContainer.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutAuthData) }
+ extraContent.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutAuthData) }
}
- extraContentContainer.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutUsername) }
- extraContent.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutUsername) }
if (entry.hasUsername()) {
usernameText.typeface = monoTypeface
@@ -180,10 +190,29 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
} else {
usernameTextContainer.visibility = View.GONE
}
+
+ if (entry.hasTotp()) {
+ otpTextContainer.visibility = View.VISIBLE
+ otpTextContainer.setEndIconOnClickListener {
+ copyTextToClipboard(
+ otpText.text.toString(),
+ snackbarTextRes = R.string.clipboard_otp_copied_text
+ )
+ }
+ launch(Dispatchers.IO) {
+ repeat(Int.MAX_VALUE) {
+ val code = entry.calculateTotpCode() ?: "Error"
+ withContext(Dispatchers.Main) {
+ otpText.setText(code)
+ }
+ delay(30.seconds)
+ }
+ }
+ }
}
}
- if (settings.getBoolean("copy_on_decrypt", true)) {
+ if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, true)) {
copyPasswordToClipboard(entry.password)
}
} catch (e: Exception) {
diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/GetKeyIdsActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/GetKeyIdsActivity.kt
index 94d5b68c..97f6bab2 100644
--- a/app/src/main/java/com/zeapo/pwdstore/crypto/GetKeyIdsActivity.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/crypto/GetKeyIdsActivity.kt
@@ -14,6 +14,7 @@ import androidx.core.content.edit
import androidx.lifecycle.lifecycleScope
import com.github.ajalt.timberkt.Timber
import com.github.ajalt.timberkt.e
+import com.zeapo.pwdstore.utils.PreferenceKeys
import com.zeapo.pwdstore.utils.snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -69,7 +70,7 @@ class GetKeyIdsActivity : BasePgpActivity() {
?: LongArray(0)
val keys = ids.map { it.toString() }.toSet()
// use Long
- settings.edit { putStringSet("openpgp_key_ids_set", keys) }
+ settings.edit { putStringSet(PreferenceKeys.OPENPGP_KEY_IDS_SET, keys) }
snackbar(message = "PGP keys selected")
setResult(RESULT_OK)
finish()
diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt
index 67bf9926..13f9add3 100644
--- a/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt
@@ -17,16 +17,19 @@ import androidx.core.widget.doOnTextChanged
import androidx.lifecycle.lifecycleScope
import com.github.ajalt.timberkt.e
import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.zeapo.pwdstore.PasswordEntry
-import com.zeapo.pwdstore.utils.isInsideRepository
+import com.google.zxing.integration.android.IntentIntegrator
+import com.google.zxing.integration.android.IntentIntegrator.QR_CODE
import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences
import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure
import com.zeapo.pwdstore.databinding.PasswordCreationActivityBinding
+import com.zeapo.pwdstore.model.PasswordEntry
import com.zeapo.pwdstore.ui.dialogs.PasswordGeneratorDialogFragment
import com.zeapo.pwdstore.ui.dialogs.XkPasswordGeneratorDialogFragment
import com.zeapo.pwdstore.utils.PasswordRepository
+import com.zeapo.pwdstore.utils.PreferenceKeys
import com.zeapo.pwdstore.utils.commitChange
+import com.zeapo.pwdstore.utils.isInsideRepository
import com.zeapo.pwdstore.utils.snackbar
import com.zeapo.pwdstore.utils.viewBinding
import kotlinx.coroutines.Dispatchers
@@ -62,6 +65,29 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
with(binding) {
setContentView(root)
generatePassword.setOnClickListener { generatePassword() }
+ otpImportButton.setOnClickListener {
+ registerForActivityResult(StartActivityForResult()) { result ->
+ if (result.resultCode == RESULT_OK) {
+ otpImportButton.isVisible = false
+ val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data)
+ val contents = "${intentResult.contents}\n"
+ val currentExtras = extraContent.text.toString()
+ if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
+ extraContent.append("\n$contents")
+ else
+ extraContent.append(contents)
+ snackbar(message = getString(R.string.otp_import_success))
+ } else {
+ snackbar(message = getString(R.string.otp_import_failure))
+ }
+ }.launch(
+ IntentIntegrator(this@PasswordCreationActivity)
+ .setOrientationLocked(false)
+ .setBeepEnabled(false)
+ .setDesiredBarcodeFormats(QR_CODE)
+ .createScanIntent()
+ )
+ }
category.apply {
if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) {
@@ -95,7 +121,7 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
val username = filename.text.toString()
val extras = "username:$username\n${extraContent.text}"
- filename.setText("")
+ filename.text?.clear()
extraContent.setText(extras)
} else {
// User wants to disable username encryption, so we extract the
@@ -104,20 +130,20 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
val username = entry.username
// username should not be null here by the logic in
- // updateEncryptUsernameState, but it could still happen due to
+ // updateViewState, but it could still happen due to
// input lag.
if (username != null) {
filename.setText(username)
- extraContent.setText(entry.extraContentWithoutUsername)
+ extraContent.setText(entry.extraContentWithoutAuthData)
}
}
- updateEncryptUsernameState()
+ updateViewState()
}
}
listOf(filename, extraContent).forEach {
- it.doOnTextChanged { _, _, _, _ -> updateEncryptUsernameState() }
+ it.doOnTextChanged { _, _, _, _ -> updateViewState() }
}
- updateEncryptUsernameState()
+ updateViewState()
}
suggestedPass?.let {
password.setText(it)
@@ -150,7 +176,7 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
}
private fun generatePassword() {
- when (settings.getString("pref_key_pwgen_type", KEY_PWGEN_TYPE_CLASSIC)) {
+ when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE, KEY_PWGEN_TYPE_CLASSIC)) {
KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment()
.show(supportFragmentManager, "generator")
KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment()
@@ -158,17 +184,18 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
}
}
- private fun updateEncryptUsernameState() = with(binding) {
+ private fun updateViewState() = with(binding) {
+ // Use PasswordEntry to parse extras for username
+ val entry = PasswordEntry("PLACEHOLDER\n${extraContent.text}")
encryptUsername.apply {
if (visibility != View.VISIBLE)
return@with
val hasUsernameInFileName = filename.text.toString().isNotBlank()
- // Use PasswordEntry to parse extras for username
- val entry = PasswordEntry("PLACEHOLDER\n${extraContent.text}")
val hasUsernameInExtras = entry.hasUsername()
isEnabled = hasUsernameInFileName xor hasUsernameInExtras
isChecked = hasUsernameInExtras
}
+ otpImportButton.isVisible = !entry.hasTotp()
}
/**
diff --git a/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt
index c75f7ad3..ae2bbc07 100644
--- a/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt
@@ -22,6 +22,7 @@ import com.zeapo.pwdstore.git.config.ConnectionMode
import com.zeapo.pwdstore.git.config.Protocol
import com.zeapo.pwdstore.git.config.SshApiSessionFactory
import com.zeapo.pwdstore.utils.PasswordRepository
+import com.zeapo.pwdstore.utils.PreferenceKeys
import com.zeapo.pwdstore.utils.getEncryptedPrefs
import java.io.File
import java.net.URI
@@ -53,14 +54,14 @@ abstract class BaseGitActivity : AppCompatActivity() {
settings = PreferenceManager.getDefaultSharedPreferences(this)
encryptedSettings = getEncryptedPrefs("git_operation")
- protocol = Protocol.fromString(settings.getString("git_remote_protocol", null))
- connectionMode = ConnectionMode.fromString(settings.getString("git_remote_auth", null))
- serverHostname = settings.getString("git_remote_server", null) ?: ""
- serverPort = settings.getString("git_remote_port", null) ?: ""
- serverUser = settings.getString("git_remote_username", null) ?: ""
- serverPath = settings.getString("git_remote_location", null) ?: ""
- username = settings.getString("git_config_user_name", null) ?: ""
- email = settings.getString("git_config_user_email", null) ?: ""
+ protocol = Protocol.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_PROTOCOL, null))
+ connectionMode = ConnectionMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH, null))
+ serverHostname = settings.getString(PreferenceKeys.GIT_REMOTE_SERVER, null) ?: ""
+ serverPort = settings.getString(PreferenceKeys.GIT_REMOTE_PORT, null) ?: ""
+ serverUser = settings.getString(PreferenceKeys.GIT_REMOTE_USERNAME, null) ?: ""
+ serverPath = settings.getString(PreferenceKeys.GIT_REMOTE_LOCATION, null) ?: ""
+ username = settings.getString(PreferenceKeys.GIT_CONFIG_USER_NAME, null) ?: ""
+ email = settings.getString(PreferenceKeys.GIT_CONFIG_USER_EMAIL, null) ?: ""
updateUrl()
}
@@ -148,7 +149,7 @@ abstract class BaseGitActivity : AppCompatActivity() {
PasswordRepository.addRemote("origin", newUrl, true)
// When the server changes, remote password and host key file should be deleted.
if (previousUrl.isNotEmpty() && newUrl != previousUrl) {
- encryptedSettings.edit { remove("https_password") }
+ encryptedSettings.edit { remove(PreferenceKeys.HTTPS_PASSWORD) }
File("$filesDir/.host_key").delete()
}
url = newUrl
diff --git a/app/src/main/java/com/zeapo/pwdstore/git/BreakOutOfDetached.kt b/app/src/main/java/com/zeapo/pwdstore/git/BreakOutOfDetached.kt
index 31c376eb..8533187f 100644
--- a/app/src/main/java/com/zeapo/pwdstore/git/BreakOutOfDetached.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/git/BreakOutOfDetached.kt
@@ -58,7 +58,7 @@ class BreakOutOfDetached(fileDir: File, callingActivity: Activity) : GitOperatio
}
}
}
- GitAsyncTask(callingActivity, true, this, null)
+ GitAsyncTask(callingActivity, this, null)
.execute(*this.commands.toTypedArray())
}
diff --git a/app/src/main/java/com/zeapo/pwdstore/git/CloneOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/CloneOperation.kt
index 705566be..0e89af0f 100644
--- a/app/src/main/java/com/zeapo/pwdstore/git/CloneOperation.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/git/CloneOperation.kt
@@ -36,7 +36,7 @@ class CloneOperation(fileDir: File, callingActivity: Activity) : GitOperation(fi
override fun execute() {
(this.command as? CloneCommand)?.setCredentialsProvider(this.provider)
- GitAsyncTask(callingActivity, false, this, Intent()).execute(this.command)
+ GitAsyncTask(callingActivity, this, Intent()).execute(this.command)
}
override fun onError(err: Exception) {
diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.kt
index a2b4e2a8..84e08dfc 100644
--- a/app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.kt
@@ -10,7 +10,6 @@ import android.content.Context
import android.content.Intent
import android.os.AsyncTask
import com.github.ajalt.timberkt.e
-import com.zeapo.pwdstore.PasswordStore
import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.git.config.SshjSessionFactory
import net.schmizz.sshj.common.DisconnectReason
@@ -30,7 +29,6 @@ import java.lang.ref.WeakReference
class GitAsyncTask(
activity: Activity,
- private val refreshListOnEnd: Boolean,
private val operation: GitOperation,
private val finishWithResultOnEnd: Intent?,
private val silentlyExecute: Boolean = false
@@ -170,9 +168,6 @@ class GitAsyncTask(
}
}
}
- if (refreshListOnEnd) {
- (activity as? PasswordStore)?.resetPasswordList()
- }
(SshSessionFactory.getInstance() as? SshjSessionFactory)?.clearCredentials()
SshSessionFactory.setInstance(null)
}
diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt
index be911a3a..35cbc68a 100644
--- a/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt
@@ -14,6 +14,7 @@ import com.google.android.material.snackbar.Snackbar
import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.databinding.ActivityGitConfigBinding
import com.zeapo.pwdstore.utils.PasswordRepository
+import com.zeapo.pwdstore.utils.PreferenceKeys
import com.zeapo.pwdstore.utils.viewBinding
import org.eclipse.jgit.lib.Constants
@@ -58,8 +59,8 @@ class GitConfigActivity : BaseGitActivity() {
.show()
} else {
settings.edit {
- putString("git_config_user_email", email)
- putString("git_config_user_name", name)
+ putString(PreferenceKeys.GIT_CONFIG_USER_EMAIL, email)
+ putString(PreferenceKeys.GIT_CONFIG_USER_NAME, name)
}
PasswordRepository.setUserName(name)
PasswordRepository.setUserEmail(email)
diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitOperation.kt
index 8cf09b39..48c28920 100644
--- a/app/src/main/java/com/zeapo/pwdstore/git/GitOperation.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/git/GitOperation.kt
@@ -9,6 +9,7 @@ import android.app.Activity
import android.content.Intent
import android.view.LayoutInflater
import androidx.annotation.StringRes
+import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import com.google.android.material.checkbox.MaterialCheckBox
@@ -22,6 +23,7 @@ import com.zeapo.pwdstore.git.config.SshApiSessionFactory
import com.zeapo.pwdstore.git.config.SshAuthData
import com.zeapo.pwdstore.git.config.SshjSessionFactory
import com.zeapo.pwdstore.utils.PasswordRepository
+import com.zeapo.pwdstore.utils.PreferenceKeys
import com.zeapo.pwdstore.utils.getEncryptedPrefs
import com.zeapo.pwdstore.utils.requestInputFocusOnView
import net.schmizz.sshj.userauth.password.PasswordFinder
@@ -35,6 +37,7 @@ import org.eclipse.jgit.transport.URIish
import java.io.File
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
+import com.google.android.material.R as materialR
private class GitOperationCredentialFinder(val callingActivity: Activity, val connectionMode: ConnectionMode) : InteractivePasswordFinder() {
@@ -48,7 +51,7 @@ private class GitOperationCredentialFinder(val callingActivity: Activity, val co
@StringRes val errorRes: Int
when (connectionMode) {
ConnectionMode.SshKey -> {
- credentialPref = "ssh_key_local_passphrase"
+ 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
@@ -56,7 +59,7 @@ private class GitOperationCredentialFinder(val callingActivity: Activity, val co
}
ConnectionMode.Password -> {
// Could be either an SSH or an HTTPS password
- credentialPref = "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
@@ -77,7 +80,8 @@ private class GitOperationCredentialFinder(val callingActivity: Activity, val co
val rememberCredential = dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_credential)
rememberCredential.setText(rememberRes)
if (isRetry)
- editCredential.error = callingActivity.resources.getString(errorRes)
+ editCredential.setError(callingActivity.resources.getString(errorRes),
+ ContextCompat.getDrawable(callingActivity, materialR.drawable.mtrl_ic_error))
MaterialAlertDialogBuilder(callingActivity).run {
setTitle(R.string.passphrase_dialog_title)
setMessage(messageRes)
@@ -219,14 +223,14 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Activity
when (SshSessionFactory.getInstance()) {
is SshApiSessionFactory -> {
PreferenceManager.getDefaultSharedPreferences(callingActivity.applicationContext)
- .edit { remove("ssh_openkeystore_keyid") }
+ .edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) }
}
is SshjSessionFactory -> {
callingActivity.applicationContext
.getEncryptedPrefs("git_operation")
.edit {
- remove("ssh_key_local_passphrase")
- remove("https_password")
+ remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE)
+ remove(PreferenceKeys.HTTPS_PASSWORD)
}
}
}
diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt
index 7a3978ba..10f44960 100644
--- a/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt
@@ -17,6 +17,7 @@ import com.zeapo.pwdstore.databinding.ActivityGitCloneBinding
import com.zeapo.pwdstore.git.config.ConnectionMode
import com.zeapo.pwdstore.git.config.Protocol
import com.zeapo.pwdstore.utils.PasswordRepository
+import com.zeapo.pwdstore.utils.PreferenceKeys
import com.zeapo.pwdstore.utils.viewBinding
import java.io.IOException
@@ -107,12 +108,12 @@ class GitServerConfigActivity : BaseGitActivity() {
when (val result = updateUrl()) {
GitUpdateUrlResult.Ok -> {
settings.edit {
- putString("git_remote_protocol", protocol.pref)
- putString("git_remote_auth", connectionMode.pref)
- putString("git_remote_server", serverHostname)
- putString("git_remote_port", serverPort)
- putString("git_remote_username", serverUser)
- putString("git_remote_location", serverPath)
+ putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, protocol.pref)
+ putString(PreferenceKeys.GIT_REMOTE_AUTH, connectionMode.pref)
+ putString(PreferenceKeys.GIT_REMOTE_SERVER, serverHostname)
+ putString(PreferenceKeys.GIT_REMOTE_PORT, serverPort)
+ putString(PreferenceKeys.GIT_REMOTE_USERNAME, serverUser)
+ putString(PreferenceKeys.GIT_REMOTE_LOCATION, serverPath)
}
if (!isClone) {
Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show()
diff --git a/app/src/main/java/com/zeapo/pwdstore/git/PullOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/PullOperation.kt
index 2d5f6bbe..c543f885 100644
--- a/app/src/main/java/com/zeapo/pwdstore/git/PullOperation.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/git/PullOperation.kt
@@ -35,7 +35,7 @@ class PullOperation(fileDir: File, callingActivity: Activity) : GitOperation(fil
override fun execute() {
(this.command as? PullCommand)?.setCredentialsProvider(this.provider)
- GitAsyncTask(callingActivity, false, this, Intent()).execute(this.command)
+ GitAsyncTask(callingActivity, this, Intent()).execute(this.command)
}
override fun onError(err: Exception) {
diff --git a/app/src/main/java/com/zeapo/pwdstore/git/PushOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/PushOperation.kt
index 52e6e537..fc58705f 100644
--- a/app/src/main/java/com/zeapo/pwdstore/git/PushOperation.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/git/PushOperation.kt
@@ -35,7 +35,7 @@ class PushOperation(fileDir: File, callingActivity: Activity) : GitOperation(fil
override fun execute() {
(this.command as? PushCommand)?.setCredentialsProvider(this.provider)
- GitAsyncTask(callingActivity, false, this, Intent()).execute(this.command)
+ GitAsyncTask(callingActivity, this, Intent()).execute(this.command)
}
override fun onError(err: Exception) {
diff --git a/app/src/main/java/com/zeapo/pwdstore/git/ResetToRemoteOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/ResetToRemoteOperation.kt
index c652889d..be9e929f 100644
--- a/app/src/main/java/com/zeapo/pwdstore/git/ResetToRemoteOperation.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/git/ResetToRemoteOperation.kt
@@ -41,7 +41,7 @@ class ResetToRemoteOperation(fileDir: File, callingActivity: Activity) : GitOper
override fun execute() {
this.fetchCommand?.setCredentialsProvider(this.provider)
- GitAsyncTask(callingActivity, false, this, Intent())
+ GitAsyncTask(callingActivity, this, Intent())
.execute(this.addCommand, this.fetchCommand, this.resetCommand)
}
diff --git a/app/src/main/java/com/zeapo/pwdstore/git/SyncOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/SyncOperation.kt
index 1f241c64..3af6f08d 100644
--- a/app/src/main/java/com/zeapo/pwdstore/git/SyncOperation.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/git/SyncOperation.kt
@@ -50,7 +50,7 @@ class SyncOperation(fileDir: File, callingActivity: Activity) : GitOperation(fil
this.pullCommand?.setCredentialsProvider(this.provider)
this.pushCommand?.setCredentialsProvider(this.provider)
}
- GitAsyncTask(callingActivity, false, this, Intent()).execute(this.addCommand, this.statusCommand, this.commitCommand, this.pullCommand, this.pushCommand)
+ GitAsyncTask(callingActivity, this, Intent()).execute(this.addCommand, this.statusCommand, this.commitCommand, this.pullCommand, this.pushCommand)
}
override fun onError(err: Exception) {
diff --git a/app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java b/app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java
index e5a5fd17..5ad12ef8 100644
--- a/app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java
+++ b/app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java
@@ -21,6 +21,7 @@ import com.jcraft.jsch.Session;
import com.jcraft.jsch.UserInfo;
import com.zeapo.pwdstore.R;
import com.zeapo.pwdstore.git.BaseGitActivity;
+import com.zeapo.pwdstore.utils.PreferenceKeys;
import org.eclipse.jgit.errors.UnsupportedCredentialItem;
import org.eclipse.jgit.transport.CredentialItem;
@@ -51,8 +52,8 @@ public class SshApiSessionFactory extends JschConfigSessionFactory {
*/
public static final int POST_SIGNATURE = 301;
- private String username;
- private Identity identity;
+ private final String username;
+ private final Identity identity;
public SshApiSessionFactory(String username, Identity identity) {
this.username = username;
@@ -108,12 +109,12 @@ public class SshApiSessionFactory extends JschConfigSessionFactory {
* build.
*/
public static class IdentityBuilder {
- private SshAuthenticationConnection connection;
+ private final SshAuthenticationConnection connection;
private SshAuthenticationApi api;
private String keyId, description, alg;
private byte[] publicKey;
- private BaseGitActivity callingActivity;
- private SharedPreferences settings;
+ private final BaseGitActivity callingActivity;
+ private final SharedPreferences settings;
/**
* Construct a new IdentityBuilder
@@ -137,7 +138,7 @@ public class SshApiSessionFactory extends JschConfigSessionFactory {
settings =
PreferenceManager.getDefaultSharedPreferences(
callingActivity.getApplicationContext());
- keyId = settings.getString("ssh_openkeystore_keyid", null);
+ keyId = settings.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null);
}
/**
@@ -163,7 +164,7 @@ public class SshApiSessionFactory extends JschConfigSessionFactory {
SshAuthenticationApiError error =
result.getParcelableExtra(SshAuthenticationApi.EXTRA_ERROR);
// On an OpenKeychain SSH API error, clear out the stored keyid
- settings.edit().putString("ssh_openkeystore_keyid", null).apply();
+ settings.edit().putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null).apply();
switch (error.getError()) {
// If the problem was just a bad keyid, reset to allow them to choose a
@@ -214,7 +215,7 @@ public class SshApiSessionFactory extends JschConfigSessionFactory {
if (intent.hasExtra(SshAuthenticationApi.EXTRA_KEY_ID)) {
keyId = intent.getStringExtra(SshAuthenticationApi.EXTRA_KEY_ID);
description = intent.getStringExtra(SshAuthenticationApi.EXTRA_KEY_DESCRIPTION);
- settings.edit().putString("ssh_openkeystore_keyid", keyId).apply();
+ settings.edit().putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, keyId).apply();
}
if (intent.hasExtra(SshAuthenticationApi.EXTRA_SSH_PUBLIC_KEY)) {
@@ -284,10 +285,12 @@ public class SshApiSessionFactory extends JschConfigSessionFactory {
* A Jsch identity that delegates key operations via the OpenKeychain SSH API
*/
public static class ApiIdentity implements Identity {
- private String keyId, description, alg;
- private byte[] publicKey;
- private Activity callingActivity;
- private SshAuthenticationApi api;
+ private final String keyId;
+ private final String description;
+ private final String alg;
+ private final byte[] publicKey;
+ private final Activity callingActivity;
+ private final SshAuthenticationApi api;
private CountDownLatch latch;
private byte[] signature;
diff --git a/app/src/main/java/com/zeapo/pwdstore/git/config/SshjConfig.kt b/app/src/main/java/com/zeapo/pwdstore/git/config/SshjConfig.kt
new file mode 100644
index 00000000..3e67eba7
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/git/config/SshjConfig.kt
@@ -0,0 +1,271 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package com.zeapo.pwdstore.git.config
+
+import com.github.ajalt.timberkt.Timber
+import com.github.ajalt.timberkt.d
+import com.hierynomus.sshj.signature.SignatureEdDSA
+import com.hierynomus.sshj.transport.cipher.BlockCiphers
+import com.hierynomus.sshj.transport.mac.Macs
+import com.hierynomus.sshj.userauth.keyprovider.OpenSSHKeyV1KeyFile
+import net.schmizz.keepalive.KeepAliveProvider
+import net.schmizz.sshj.ConfigImpl
+import net.schmizz.sshj.common.LoggerFactory
+import net.schmizz.sshj.signature.SignatureECDSA
+import net.schmizz.sshj.signature.SignatureRSA
+import net.schmizz.sshj.signature.SignatureRSA.FactoryCERT
+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
+import java.security.Security
+
+
+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
+ try {
+ Class.forName("sun.security.jca.Providers")
+ } catch (e: ClassNotFoundException) {
+ }
+ Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1)
+ }
+ d { "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" }
+}
+
+private abstract class AbstractLogger(private val name: String) : Logger {
+
+ abstract fun t(message: String, t: Throwable? = null, vararg args: Any?)
+ abstract fun d(message: String, t: Throwable? = null, vararg args: Any?)
+ abstract fun i(message: String, t: Throwable? = null, vararg args: Any?)
+ abstract fun w(message: String, t: Throwable? = null, vararg args: Any?)
+ abstract fun e(message: String, t: Throwable? = null, vararg args: Any?)
+
+ override fun getName() = name
+
+ override fun isTraceEnabled(marker: Marker?): Boolean = isTraceEnabled
+ override fun isDebugEnabled(marker: Marker?): Boolean = isDebugEnabled
+ override fun isInfoEnabled(marker: Marker?): Boolean = isInfoEnabled
+ override fun isWarnEnabled(marker: Marker?): Boolean = isWarnEnabled
+ override fun isErrorEnabled(marker: Marker?): Boolean = isErrorEnabled
+
+ override fun trace(msg: String) = t(msg)
+ override fun trace(format: String, arg: Any?) = t(format, null, arg)
+ override fun trace(format: String, arg1: Any?, arg2: Any?) = t(format, null, arg1, arg2)
+ override fun trace(format: String, vararg arguments: Any?) = t(format, null, *arguments)
+ override fun trace(msg: String, t: Throwable?) = t(msg, t)
+ override fun trace(marker: Marker, msg: String) = trace(msg)
+ override fun trace(marker: Marker?, format: String, arg: Any?) = trace(format, arg)
+ override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
+ trace(format, arg1, arg2)
+
+ override fun trace(marker: Marker?, format: String, vararg arguments: Any?) =
+ trace(format, *arguments)
+
+ override fun trace(marker: Marker?, msg: String, t: Throwable?) = trace(msg, t)
+
+ override fun debug(msg: String) = d(msg)
+ override fun debug(format: String, arg: Any?) = d(format, null, arg)
+ override fun debug(format: String, arg1: Any?, arg2: Any?) = d(format, null, arg1, arg2)
+ override fun debug(format: String, vararg arguments: Any?) = d(format, null, *arguments)
+ override fun debug(msg: String, t: Throwable?) = d(msg, t)
+ override fun debug(marker: Marker, msg: String) = debug(msg)
+ override fun debug(marker: Marker?, format: String, arg: Any?) = debug(format, arg)
+ override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
+ debug(format, arg1, arg2)
+
+ override fun debug(marker: Marker?, format: String, vararg arguments: Any?) =
+ debug(format, *arguments)
+
+ override fun debug(marker: Marker?, msg: String, t: Throwable?) = debug(msg, t)
+
+ override fun info(msg: String) = i(msg)
+ override fun info(format: String, arg: Any?) = i(format, null, arg)
+ override fun info(format: String, arg1: Any?, arg2: Any?) = i(format, null, arg1, arg2)
+ override fun info(format: String, vararg arguments: Any?) = i(format, null, *arguments)
+ override fun info(msg: String, t: Throwable?) = i(msg, t)
+ override fun info(marker: Marker, msg: String) = info(msg)
+ override fun info(marker: Marker?, format: String, arg: Any?) = info(format, arg)
+ override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
+ info(format, arg1, arg2)
+
+ override fun info(marker: Marker?, format: String, vararg arguments: Any?) =
+ info(format, *arguments)
+
+ override fun info(marker: Marker?, msg: String, t: Throwable?) = info(msg, t)
+
+ override fun warn(msg: String) = w(msg)
+ override fun warn(format: String, arg: Any?) = w(format, null, arg)
+ override fun warn(format: String, arg1: Any?, arg2: Any?) = w(format, null, arg1, arg2)
+ override fun warn(format: String, vararg arguments: Any?) = w(format, null, *arguments)
+ override fun warn(msg: String, t: Throwable?) = w(msg, t)
+ override fun warn(marker: Marker, msg: String) = warn(msg)
+ override fun warn(marker: Marker?, format: String, arg: Any?) = warn(format, arg)
+ override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
+ warn(format, arg1, arg2)
+
+ override fun warn(marker: Marker?, format: String, vararg arguments: Any?) =
+ warn(format, *arguments)
+
+ override fun warn(marker: Marker?, msg: String, t: Throwable?) = warn(msg, t)
+
+ override fun error(msg: String) = e(msg)
+ override fun error(format: String, arg: Any?) = e(format, null, arg)
+ override fun error(format: String, arg1: Any?, arg2: Any?) = e(format, null, arg1, arg2)
+ override fun error(format: String, vararg arguments: Any?) = e(format, null, *arguments)
+ override fun error(msg: String, t: Throwable?) = e(msg, t)
+ override fun error(marker: Marker, msg: String) = error(msg)
+ override fun error(marker: Marker?, format: String, arg: Any?) = error(format, arg)
+ override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
+ error(format, arg1, arg2)
+
+ override fun error(marker: Marker?, format: String, vararg arguments: Any?) =
+ error(format, *arguments)
+
+ override fun error(marker: Marker?, msg: String, t: Throwable?) = error(msg, t)
+}
+
+object TimberLoggerFactory : LoggerFactory {
+ private class TimberLogger(name: String) : AbstractLogger(name) {
+
+ // We defer the log level checks to Timber.
+ override fun isTraceEnabled() = true
+ override fun isDebugEnabled() = true
+ override fun isInfoEnabled() = true
+ override fun isWarnEnabled() = true
+ override fun isErrorEnabled() = true
+
+ // Replace slf4j's "{}" format string style with standard Java's "%s".
+ // The supposedly redundant escape on the } is not redundant.
+ @Suppress("RegExpRedundantEscape")
+ private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s")
+
+ override fun t(message: String, t: Throwable?, vararg args: Any?) {
+ Timber.tag(name).v(t, message.fix(), *args)
+ }
+
+ override fun d(message: String, t: Throwable?, vararg args: Any?) {
+ Timber.tag(name).d(t, message.fix(), *args)
+ }
+
+ override fun i(message: String, t: Throwable?, vararg args: Any?) {
+ Timber.tag(name).i(t, message.fix(), *args)
+ }
+
+ override fun w(message: String, t: Throwable?, vararg args: Any?) {
+ Timber.tag(name).w(t, message.fix(), *args)
+ }
+
+ override fun e(message: String, t: Throwable?, vararg args: Any?) {
+ Timber.tag(name).e(t, message.fix(), *args)
+ }
+ }
+
+ override fun getLogger(name: String): Logger {
+ return TimberLogger(name)
+ }
+
+ override fun getLogger(clazz: Class<*>): Logger {
+ return TimberLogger(clazz.name)
+ }
+
+}
+
+class SshjConfig : ConfigImpl() {
+
+ init {
+ loggerFactory = TimberLoggerFactory
+ keepAliveProvider = KeepAliveProvider.HEARTBEAT
+
+ initKeyExchangeFactories()
+ initSignatureFactories()
+ initRandomFactory()
+ initFileKeyProviderFactories()
+ initCipherFactories()
+ initCompressionFactories()
+ initMACFactories()
+ }
+
+ private fun initKeyExchangeFactories() {
+ keyExchangeFactories = listOf(
+ Curve25519SHA256.Factory(),
+ FactoryLibSsh(),
+ ECDHNistP.Factory521(),
+ ECDHNistP.Factory384(),
+ ECDHNistP.Factory256(),
+ DHGexSHA256.Factory()
+ )
+ }
+
+ private fun initSignatureFactories() {
+ signatureFactories = listOf(
+ SignatureEdDSA.Factory(),
+ SignatureECDSA.Factory256(),
+ SignatureECDSA.Factory384(),
+ SignatureECDSA.Factory521(),
+ SignatureRSA.Factory(),
+ FactoryCERT()
+ )
+ }
+
+ private fun initRandomFactory() {
+ randomFactory = SingletonRandomFactory(JCERandom.Factory())
+ }
+
+ private fun initFileKeyProviderFactories() {
+ fileKeyProviderFactories = listOf(
+ OpenSSHKeyV1KeyFile.Factory(),
+ PKCS8KeyFile.Factory(),
+ PKCS5KeyFile.Factory(),
+ OpenSSHKeyFile.Factory(),
+ PuTTYKeyFile.Factory()
+ )
+ }
+
+
+ private fun initCipherFactories() {
+ cipherFactories = listOf(
+ BlockCiphers.AES128CTR(),
+ BlockCiphers.AES192CTR(),
+ BlockCiphers.AES256CTR()
+ )
+ }
+
+ private fun initMACFactories() {
+ macFactories = listOf(
+ Macs.HMACSHA2256(),
+ Macs.HMACSHA2256Etm(),
+ Macs.HMACSHA2512(),
+ Macs.HMACSHA2512Etm()
+ )
+ }
+
+ private fun initCompressionFactories() {
+ compressionFactories = listOf(
+ NoneCompression.Factory()
+ )
+ }
+}
diff --git a/app/src/main/java/com/zeapo/pwdstore/git/config/SshjSessionFactory.kt b/app/src/main/java/com/zeapo/pwdstore/git/config/SshjSessionFactory.kt
index f900e959..45e7fe3e 100644
--- a/app/src/main/java/com/zeapo/pwdstore/git/config/SshjSessionFactory.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/git/config/SshjSessionFactory.kt
@@ -132,7 +132,7 @@ private class SshjSession(private val uri: URIish, private val username: String,
private var currentCommand: Session? = null
fun connect(): SshjSession {
- ssh = SSHClient()
+ ssh = SSHClient(SshjConfig())
ssh.addHostKeyVerifier(makeTofuHostKeyVerifier(hostKeyFile))
ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22)
if (!ssh.isConnected)
diff --git a/app/src/main/java/com/zeapo/pwdstore/model/PasswordEntry.kt b/app/src/main/java/com/zeapo/pwdstore/model/PasswordEntry.kt
new file mode 100644
index 00000000..27a0c584
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/model/PasswordEntry.kt
@@ -0,0 +1,121 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package com.zeapo.pwdstore.model
+
+import com.zeapo.pwdstore.utils.Otp
+import com.zeapo.pwdstore.utils.TotpFinder
+import com.zeapo.pwdstore.utils.UriTotpFinder
+import java.io.ByteArrayOutputStream
+import java.io.UnsupportedEncodingException
+import java.util.Date
+
+/**
+ * A single entry in password store. [totpFinder] is an implementation of [TotpFinder] that let's us
+ * abstract out the Android-specific part and continue testing the class in the JVM.
+ */
+class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTotpFinder()) {
+
+ val password: String
+ val username: String?
+ val digits: String
+ val totpSecret: String?
+ val totpPeriod: Long
+ val totpAlgorithm: String
+ var extraContent: String
+ private set
+
+ @Throws(UnsupportedEncodingException::class)
+ constructor(os: ByteArrayOutputStream) : this(os.toString("UTF-8"), UriTotpFinder())
+
+ init {
+ val passContent = content.split("\n".toRegex(), 2).toTypedArray()
+ password = passContent[0]
+ extraContent = findExtraContent(passContent)
+ username = findUsername()
+ digits = findOtpDigits(content)
+ totpSecret = findTotpSecret(content)
+ totpPeriod = findTotpPeriod(content)
+ totpAlgorithm = findTotpAlgorithm(content)
+ }
+
+ fun hasExtraContent(): Boolean {
+ return extraContent.isNotEmpty()
+ }
+
+ fun hasTotp(): Boolean {
+ return totpSecret != null
+ }
+
+ fun hasUsername(): Boolean {
+ return username != null
+ }
+
+ fun calculateTotpCode(): String? {
+ if (totpSecret == null)
+ return null
+ return Otp.calculateCode(totpSecret, Date().time / (1000 * totpPeriod), totpAlgorithm, digits)
+ }
+
+ val extraContentWithoutAuthData by lazy {
+ extraContent.splitToSequence("\n").filter { line ->
+ return@filter when {
+ USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) } -> {
+ false
+ }
+ line.startsWith("otpauth://", ignoreCase = true) ||
+ line.startsWith("totp:", ignoreCase = true) -> {
+ false
+ }
+ else -> {
+ true
+ }
+ }
+ }.joinToString(separator = "\n")
+ }
+
+ private fun findUsername(): String? {
+ extraContent.splitToSequence("\n").forEach { line ->
+ for (prefix in USERNAME_FIELDS) {
+ if (line.startsWith(prefix, ignoreCase = true))
+ return line.substring(prefix.length).trimStart()
+ }
+ }
+ return null
+ }
+
+ private fun findExtraContent(passContent: Array<String>): String {
+ return if (passContent.size > 1) passContent[1] else ""
+ }
+
+ private fun findTotpSecret(decryptedContent: String): String? {
+ return totpFinder.findSecret(decryptedContent)
+ }
+
+ private fun findOtpDigits(decryptedContent: String): String {
+ return totpFinder.findDigits(decryptedContent)
+ }
+
+ private fun findTotpPeriod(decryptedContent: String): Long {
+ return totpFinder.findPeriod(decryptedContent)
+ }
+
+ private fun findTotpAlgorithm(decryptedContent: String): String {
+ return totpFinder.findAlgorithm(decryptedContent)
+ }
+
+ companion object {
+ val USERNAME_FIELDS = arrayOf(
+ "login:",
+ "username:",
+ "user:",
+ "account:",
+ "email:",
+ "name:",
+ "handle:",
+ "id:",
+ "identity:"
+ )
+ }
+}
diff --git a/app/src/main/java/com/zeapo/pwdstore/pwgen/PasswordGenerator.kt b/app/src/main/java/com/zeapo/pwdstore/pwgen/PasswordGenerator.kt
index b4accec7..38c82d4a 100644
--- a/app/src/main/java/com/zeapo/pwdstore/pwgen/PasswordGenerator.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/pwgen/PasswordGenerator.kt
@@ -7,6 +7,7 @@ package com.zeapo.pwdstore.pwgen
import android.content.Context
import androidx.core.content.edit
import com.zeapo.pwdstore.R
+import com.zeapo.pwdstore.utils.PreferenceKeys
import com.zeapo.pwdstore.utils.clearFlag
import com.zeapo.pwdstore.utils.hasFlag
@@ -102,7 +103,7 @@ object PasswordGenerator {
}
}
- val length = prefs.getInt("length", DEFAULT_LENGTH)
+ val length = prefs.getInt(PreferenceKeys.LENGTH, DEFAULT_LENGTH)
if (pwgenFlags.clearFlag(NO_AMBIGUOUS) == 0) {
throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_no_chars_error))
}
diff --git a/app/src/main/java/com/zeapo/pwdstore/pwgenxkpwd/XkpwdDictionary.kt b/app/src/main/java/com/zeapo/pwdstore/pwgenxkpwd/XkpwdDictionary.kt
index 92438ed0..37878b70 100644
--- a/app/src/main/java/com/zeapo/pwdstore/pwgenxkpwd/XkpwdDictionary.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/pwgenxkpwd/XkpwdDictionary.kt
@@ -7,6 +7,7 @@ package com.zeapo.pwdstore.pwgenxkpwd
import android.content.Context
import androidx.preference.PreferenceManager
import com.zeapo.pwdstore.R
+import com.zeapo.pwdstore.utils.PreferenceKeys
import java.io.File
class XkpwdDictionary(context: Context) {
@@ -14,10 +15,10 @@ class XkpwdDictionary(context: Context) {
init {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
- val uri = prefs.getString("pref_key_custom_dict", "")!!
+ val uri = prefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT, "")!!
val customDictFile = File(context.filesDir, XKPWD_CUSTOM_DICT_FILE)
- val lines = if (prefs.getBoolean("pref_key_is_custom_dict", false) &&
+ val lines = if (prefs.getBoolean(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT, false) &&
uri.isNotEmpty() && customDictFile.canRead()) {
customDictFile.readLines()
} else {
diff --git a/app/src/main/java/com/zeapo/pwdstore/sshkeygen/ShowSshKeyFragment.kt b/app/src/main/java/com/zeapo/pwdstore/sshkeygen/ShowSshKeyFragment.kt
index 8f4fbf84..109ebd01 100644
--- a/app/src/main/java/com/zeapo/pwdstore/sshkeygen/ShowSshKeyFragment.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/sshkeygen/ShowSshKeyFragment.kt
@@ -36,7 +36,7 @@ class ShowSshKeyFragment : DialogFragment() {
createMaterialDialog(view)
val ad = builder.create()
ad.setOnShowListener {
- val b = ad.getButton(AlertDialog.BUTTON_NEUTRAL)
+ val b = ad.getButton(AlertDialog.BUTTON_POSITIVE)
b.setOnClickListener {
val clipboard = activity.clipboard ?: return@setOnClickListener
val clip = ClipData.newPlainText("public key", publicKey.text.toString())
@@ -49,9 +49,8 @@ class ShowSshKeyFragment : DialogFragment() {
private fun createMaterialDialog(view: View) {
builder.setView(view)
builder.setTitle(getString(R.string.your_public_key))
- builder.setPositiveButton(getString(R.string.dialog_ok)) { _, _ -> requireActivity().finish() }
- builder.setNegativeButton(getString(R.string.dialog_cancel), null)
- builder.setNeutralButton(resources.getString(R.string.ssh_keygen_copy), null)
+ builder.setNegativeButton(R.string.dialog_ok) { _, _ -> requireActivity().finish() }
+ builder.setPositiveButton(R.string.ssh_keygen_copy, null)
}
private fun readKeyFromFile() {
diff --git a/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenActivity.kt b/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenActivity.kt
index 2bb04e20..0f844b31 100644
--- a/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenActivity.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenActivity.kt
@@ -6,18 +6,48 @@ package com.zeapo.pwdstore.sshkeygen
import android.os.Bundle
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 androidx.preference.PreferenceManager
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.jcraft.jsch.JSch
+import com.jcraft.jsch.KeyPair
+import com.zeapo.pwdstore.R
+import com.zeapo.pwdstore.databinding.ActivitySshKeygenBinding
+import com.zeapo.pwdstore.utils.getEncryptedPrefs
+import com.zeapo.pwdstore.utils.viewBinding
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileOutputStream
class SshKeyGenActivity : AppCompatActivity() {
- public override fun onCreate(savedInstanceState: Bundle?) {
+ private var keyLength = 4096
+ private val binding by viewBinding(ActivitySshKeygenBinding::inflate)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ setContentView(binding.root)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
- if (savedInstanceState == null) {
- supportFragmentManager
- .beginTransaction()
- .replace(android.R.id.content, SshKeyGenFragment())
- .commit()
+ with(binding) {
+ generate.setOnClickListener {
+ lifecycleScope.launch { generate(passphrase.text.toString(), comment.text.toString()) }
+ }
+ keyLengthGroup.check(R.id.key_length_4096)
+ keyLengthGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
+ if (isChecked) {
+ when (checkedId) {
+ R.id.key_length_2048 -> keyLength = 2048
+ R.id.key_length_4096 -> keyLength = 4096
+ }
+ }
+ }
}
}
@@ -31,4 +61,56 @@ class SshKeyGenActivity : AppCompatActivity() {
else -> super.onOptionsItemSelected(item)
}
}
+
+ private suspend fun generate(passphrase: String, comment: String) {
+ binding.generate.text = getString(R.string.ssh_key_gen_generating_progress)
+ val e = try {
+ withContext(Dispatchers.IO) {
+ val kp = KeyPair.genKeyPair(JSch(), KeyPair.RSA, keyLength)
+ var file = File(filesDir, ".ssh_key")
+ var out = FileOutputStream(file, false)
+ if (passphrase.isNotEmpty()) {
+ kp?.writePrivateKey(out, passphrase.toByteArray())
+ } else {
+ kp?.writePrivateKey(out)
+ }
+ file = File(filesDir, ".ssh_key.pub")
+ out = FileOutputStream(file, false)
+ kp?.writePublicKey(out, comment)
+ }
+ null
+ } catch (e: Exception) {
+ e.printStackTrace()
+ e
+ } finally {
+ getEncryptedPrefs("git_operation").edit {
+ remove("ssh_key_local_passphrase")
+ }
+ }
+ binding.generate.text = getString(R.string.ssh_keygen_generating_done)
+ if (e == null) {
+ val df = ShowSshKeyFragment()
+ df.show(supportFragmentManager, "public_key")
+ val prefs = PreferenceManager.getDefaultSharedPreferences(this)
+ prefs.edit { putBoolean("use_generated_key", true) }
+ } else {
+ MaterialAlertDialogBuilder(this)
+ .setTitle(getString(R.string.error_generate_ssh_key))
+ .setMessage(getString(R.string.ssh_key_error_dialog_text) + e.message)
+ .setPositiveButton(getString(R.string.dialog_ok)) { _, _ ->
+ finish()
+ }
+ .show()
+ }
+ hideKeyboard()
+ }
+
+ private fun hideKeyboard() {
+ val imm = getSystemService<InputMethodManager>() ?: return
+ var view = currentFocus
+ if (view == null) {
+ view = View(this)
+ }
+ imm.hideSoftInputFromWindow(view.windowToken, 0)
+ }
}
diff --git a/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenFragment.kt b/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenFragment.kt
deleted file mode 100644
index 9b9d58f5..00000000
--- a/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenFragment.kt
+++ /dev/null
@@ -1,111 +0,0 @@
-/*
- * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package com.zeapo.pwdstore.sshkeygen
-
-import android.os.Bundle
-import android.view.View
-import android.view.inputmethod.InputMethodManager
-import androidx.core.content.edit
-import androidx.core.content.getSystemService
-import androidx.fragment.app.Fragment
-import androidx.lifecycle.lifecycleScope
-import androidx.preference.PreferenceManager
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.jcraft.jsch.JSch
-import com.jcraft.jsch.KeyPair
-import com.zeapo.pwdstore.R
-import com.zeapo.pwdstore.databinding.FragmentSshKeygenBinding
-import com.zeapo.pwdstore.utils.getEncryptedPrefs
-import com.zeapo.pwdstore.utils.viewBinding
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import java.io.File
-import java.io.FileOutputStream
-
-class SshKeyGenFragment : Fragment(R.layout.fragment_ssh_keygen) {
-
- private var keyLength = 4096
- private val binding by viewBinding(FragmentSshKeygenBinding::bind)
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- with(binding) {
- generate.setOnClickListener {
- lifecycleScope.launch { generate(passphrase.text.toString(), comment.text.toString()) }
- }
- keyLengthGroup.check(R.id.key_length_4096)
- keyLengthGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
- if (isChecked) {
- when (checkedId) {
- R.id.key_length_2048 -> keyLength = 2048
- R.id.key_length_4096 -> keyLength = 4096
- }
- }
- }
- }
- }
-
- override fun onDestroyView() {
- super.onDestroyView()
- }
-
- // Invoked when 'Generate' button of SshKeyGenFragment clicked. Generates a
- // private and public key, then replaces the SshKeyGenFragment with a
- // ShowSshKeyFragment which displays the public key.
- private suspend fun generate(passphrase: String, comment: String) {
- binding.generate.text = getString(R.string.ssh_key_gen_generating_progress)
- val e = try {
- withContext(Dispatchers.IO) {
- val kp = KeyPair.genKeyPair(JSch(), KeyPair.RSA, keyLength)
- var file = File(requireActivity().filesDir, ".ssh_key")
- var out = FileOutputStream(file, false)
- if (passphrase.isNotEmpty()) {
- kp?.writePrivateKey(out, passphrase.toByteArray())
- } else {
- kp?.writePrivateKey(out)
- }
- file = File(requireActivity().filesDir, ".ssh_key.pub")
- out = FileOutputStream(file, false)
- kp?.writePublicKey(out, comment)
- }
- null
- } catch (e: Exception) {
- e.printStackTrace()
- e
- } finally {
- requireContext().getEncryptedPrefs("git_operation").edit {
- remove("ssh_key_local_passphrase")
- }
- }
- val activity = requireActivity()
- binding.generate.text = getString(R.string.ssh_keygen_generating_done)
- if (e == null) {
- val df = ShowSshKeyFragment()
- df.show(requireActivity().supportFragmentManager, "public_key")
- val prefs = PreferenceManager.getDefaultSharedPreferences(activity)
- prefs.edit { putBoolean("use_generated_key", true) }
- } else {
- MaterialAlertDialogBuilder(activity)
- .setTitle(activity.getString(R.string.error_generate_ssh_key))
- .setMessage(activity.getString(R.string.ssh_key_error_dialog_text) + e.message)
- .setPositiveButton(activity.getString(R.string.dialog_ok)) { _, _ ->
- requireActivity().finish()
- }
- .show()
- }
- hideKeyboard()
- }
-
- private fun hideKeyboard() {
- val activity = activity ?: return
- val imm = activity.getSystemService<InputMethodManager>() ?: return
- var view = activity.currentFocus
- if (view == null) {
- view = View(activity)
- }
- imm.hideSoftInputFromWindow(view.windowToken, 0)
- }
-}
diff --git a/app/src/main/java/com/zeapo/pwdstore/ui/adapters/PasswordItemRecyclerAdapter.kt b/app/src/main/java/com/zeapo/pwdstore/ui/adapters/PasswordItemRecyclerAdapter.kt
index 91926640..fd96b7a8 100644
--- a/app/src/main/java/com/zeapo/pwdstore/ui/adapters/PasswordItemRecyclerAdapter.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/ui/adapters/PasswordItemRecyclerAdapter.kt
@@ -18,6 +18,7 @@ import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.SearchableRepositoryAdapter
import com.zeapo.pwdstore.stableId
import com.zeapo.pwdstore.utils.PasswordItem
+import com.zeapo.pwdstore.utils.PreferenceKeys
import java.io.File
open class PasswordItemRecyclerAdapter :
@@ -50,7 +51,7 @@ open class PasswordItemRecyclerAdapter :
fun bind(item: PasswordItem) {
val settings =
PreferenceManager.getDefaultSharedPreferences(itemView.context.applicationContext)
- val showHidden = settings.getBoolean("show_hidden_folders", false)
+ val showHidden = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false)
name.text = item.toString()
if (item.type == PasswordItem.TYPE_CATEGORY) {
typeImage.setImageResource(R.drawable.ic_multiple_files_24dp)
diff --git a/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/PasswordGeneratorDialogFragment.kt b/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/PasswordGeneratorDialogFragment.kt
index 160f388b..5528348f 100644
--- a/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/PasswordGeneratorDialogFragment.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/PasswordGeneratorDialogFragment.kt
@@ -24,6 +24,7 @@ import com.zeapo.pwdstore.pwgen.PasswordGenerator.PasswordGeneratorException
import com.zeapo.pwdstore.pwgen.PasswordGenerator.generate
import com.zeapo.pwdstore.pwgen.PasswordGenerator.setPrefs
import com.zeapo.pwdstore.pwgen.PasswordOption
+import com.zeapo.pwdstore.utils.PreferenceKeys
class PasswordGeneratorDialogFragment : DialogFragment() {
@@ -45,7 +46,7 @@ class PasswordGeneratorDialogFragment : DialogFragment() {
view.findViewById<CheckBox>(R.id.pronounceable)?.isChecked = !prefs.getBoolean(PasswordOption.FullyRandom.key, true)
val textView: AppCompatEditText = view.findViewById(R.id.lengthNumber)
- textView.setText(prefs.getInt("length", 20).toString())
+ textView.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString())
val passwordText: AppCompatTextView = view.findViewById(R.id.passwordText)
passwordText.typeface = monoTypeface
return MaterialAlertDialogBuilder(requireContext()).run {
diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt b/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt
index c84465e9..dcede7da 100644
--- a/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt
@@ -23,7 +23,6 @@ import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.github.ajalt.timberkt.d
import com.google.android.material.snackbar.Snackbar
-import com.zeapo.pwdstore.PasswordStore
import com.zeapo.pwdstore.git.GitAsyncTask
import com.zeapo.pwdstore.git.GitOperation
import com.zeapo.pwdstore.utils.PasswordRepository.Companion.getRepositoryDirectory
@@ -92,7 +91,7 @@ fun Activity.commitChange(message: String, finishWithResultOnEnd: Intent? = null
override fun execute() {
d { "Comitting with message: '$message'" }
val git = Git(repository)
- val task = GitAsyncTask(this@commitChange, true, this, finishWithResultOnEnd, silentlyExecute = true)
+ val task = GitAsyncTask(this@commitChange, this, finishWithResultOnEnd, silentlyExecute = true)
task.execute(
git.add().addFilepattern("."),
git.commit().setAll(true).setMessage(message)
diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/Otp.kt b/app/src/main/java/com/zeapo/pwdstore/utils/Otp.kt
new file mode 100644
index 00000000..b95b9902
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/utils/Otp.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package com.zeapo.pwdstore.utils
+
+import com.github.ajalt.timberkt.e
+import org.apache.commons.codec.binary.Base32
+import java.nio.ByteBuffer
+import java.security.InvalidKeyException
+import java.security.NoSuchAlgorithmException
+import java.util.Locale
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
+import kotlin.experimental.and
+
+object Otp {
+ private val BASE_32 = Base32()
+ private val STEAM_ALPHABET = "23456789BCDFGHJKMNPQRTVWXY".toCharArray()
+ init {
+ check(STEAM_ALPHABET.size == 26)
+ }
+
+ fun calculateCode(secret: String, counter: Long, algorithm: String, digits: String): String? {
+ val algo = "Hmac${algorithm.toUpperCase(Locale.ROOT)}"
+ val decodedSecret = try {
+ BASE_32.decode(secret)
+ } catch (e: Exception) {
+ e(e) { "Failed to decode secret" }
+ return null
+ }
+ val secretKey = SecretKeySpec(decodedSecret, algo)
+ val digest = try {
+ Mac.getInstance(algo).run {
+ init(secretKey)
+ doFinal(ByteBuffer.allocate(8).putLong(counter).array())
+ }
+ } catch (e: NoSuchAlgorithmException) {
+ e(e)
+ return null
+ } catch (e: InvalidKeyException) {
+ e(e) { "Key is malformed" }
+ return null
+ }
+ // Least significant 4 bits are used as an offset into the digest.
+ val offset = (digest.last() and 0xf).toInt()
+ // Extract 32 bits at the offset and clear the most significant bit.
+ val code = digest.copyOfRange(offset, offset + 4)
+ code[0] = (0x7f and code[0].toInt()).toByte()
+ val codeInt = ByteBuffer.wrap(code).int
+ check(codeInt > 0)
+ return if (digits == "s") {
+ // Steam
+ var remainingCodeInt = codeInt
+ buildString {
+ repeat(5) {
+ append(STEAM_ALPHABET[remainingCodeInt % 26])
+ remainingCodeInt /= 26
+ }
+ }
+ } else {
+ // Base 10, 6 to 10 digits
+ val numDigits = digits.toIntOrNull()
+ when {
+ numDigits == null -> {
+ e { "Digits specifier has to be either 's' or numeric" }
+ return null
+ }
+ numDigits < 6 -> {
+ e { "TOTP codes have to be at least 6 digits long" }
+ return null
+ }
+ numDigits > 10 -> {
+ e { "TOTP codes can be at most 10 digits long" }
+ return null
+ }
+ else -> {
+ // 2^31 = 2_147_483_648, so we can extract at most 10 digits with the first one
+ // always being 0, 1, or 2. Pad with leading zeroes.
+ val codeStringBase10 = codeInt.toString(10).padStart(10, '0')
+ check(codeStringBase10.length == 10)
+ codeStringBase10.takeLast(numDigits)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/PasswordRepository.kt b/app/src/main/java/com/zeapo/pwdstore/utils/PasswordRepository.kt
index 794630be..65109d99 100644
--- a/app/src/main/java/com/zeapo/pwdstore/utils/PasswordRepository.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/utils/PasswordRepository.kt
@@ -39,7 +39,8 @@ open class PasswordRepository protected constructor() {
companion object {
@JvmStatic
fun getSortOrder(settings: SharedPreferences): PasswordSortOrder {
- return valueOf(settings.getString("sort_order", null) ?: FOLDER_FIRST.name)
+ return valueOf(settings.getString(PreferenceKeys.SORT_ORDER, null)
+ ?: FOLDER_FIRST.name)
}
}
}
@@ -154,8 +155,8 @@ open class PasswordRepository protected constructor() {
if (!::settings.isInitialized) {
settings = PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
}
- return if (settings.getBoolean("git_external", false)) {
- val externalRepo = settings.getString("git_external_repo", null)
+ return if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) {
+ val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO, null)
if (externalRepo != null)
File(externalRepo)
else
@@ -174,9 +175,9 @@ open class PasswordRepository protected constructor() {
// uninitialize the repo if the dir does not exist or is absolutely empty
settings.edit {
if (!dir.exists() || !dir.isDirectory || dir.listFiles()!!.isEmpty()) {
- putBoolean("repository_initialized", false)
+ putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)
} else {
- putBoolean("repository_initialized", true)
+ putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true)
}
}
@@ -217,7 +218,7 @@ open class PasswordRepository protected constructor() {
// 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 showHiddenDirs = settings.getBoolean("show_hidden_folders", false)
+ val showHiddenDirs = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false)
if (passList.size == 0) return passwordList
if (showHiddenDirs) {
diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt b/app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt
new file mode 100644
index 00000000..05f9c741
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt
@@ -0,0 +1,60 @@
+package com.zeapo.pwdstore.utils
+
+object PreferenceKeys {
+
+ const val APP_THEME = "app_theme"
+ const val APP_VERSION = "app_version"
+ const val AUTOFILL_APPS = "autofill_apps"
+ const val AUTOFILL_ALWAYS = "autofill_always"
+ const val AUTOFILL_DEFAULT = "autofill_default"
+ const val AUTOFILL_ENABLE = "autofill_enable"
+ const val AUTOFILL_FULL_PATH = "autofill_full_path"
+ const val BIOMETRIC_AUTH = "biometric_auth"
+ const val CLEAR_CLIPBOARD_20X = "clear_clipboard_20x"
+ const val CLEAR_SAVED_PASS = "clear_saved_pass"
+ const val COPY_ON_DECRYPT = "copy_on_decrypt"
+ const val ENABLE_DEBUG_LOGGING = "enable_debug_logging"
+ const val EXPORT_PASSWORDS = "export_passwords"
+ const val FILTER_RECURSIVELY = "filter_recursively"
+ const val GENERAL_SHOW_TIME = "general_show_time"
+ const val GIT_CONFIG = "git_config"
+ const val GIT_CONFIG_USER_EMAIL = "git_config_user_email"
+ const val GIT_CONFIG_USER_NAME = "git_config_user_name"
+ const val GIT_EXTERNAL = "git_external"
+ const val GIT_EXTERNAL_REPO = "git_external_repo"
+ const val GIT_REMOTE_AUTH = "git_remote_auth"
+ const val GIT_REMOTE_LOCATION = "git_remote_location"
+ const val GIT_REMOTE_PORT = "git_remote_port"
+ const val GIT_REMOTE_PROTOCOL = "git_remote_protocol"
+ const val GIT_DELETE_REPO = "git_delete_repo"
+ const val GIT_REMOTE_SERVER = "git_remote_server"
+ const val GIT_REMOTE_USERNAME = "git_remote_username"
+ const val GIT_SERVER_INFO = "git_server_info"
+ const val HTTPS_PASSWORD = "https_password"
+ const val LENGTH = "length"
+ const val OPENPGP_KEY_IDS_SET = "openpgp_key_ids_set"
+ const val OPENPGP_KEY_ID_PREF = "openpgp_key_id_pref"
+ const val OPENPGP_PROVIDER_LIST = "openpgp_provider_list"
+ const val OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES = "oreo_autofill_custom_public_suffixes"
+ const val OREO_AUTOFILL_DEFAULT_USERNAME = "oreo_autofill_default_username"
+ const val OREO_AUTOFILL_DIRECTORY_STRUCTURE = "oreo_autofill_directory_structure"
+ const val PREF_KEY_CUSTOM_DICT = "pref_key_custom_dict"
+ const val PREF_KEY_IS_CUSTOM_DICT = "pref_key_is_custom_dict"
+ const val PREF_KEY_PWGEN_TYPE = "pref_key_pwgen_type"
+ const val PREF_SELECT_EXTERNAL = "pref_select_external"
+ const val REPOSITORY_INITIALIZED = "repository_initialized"
+ const val REPO_CHANGED = "repo_changed"
+ const val SEARCH_ON_START = "search_on_start"
+ const val SHOW_EXTRA_CONTENT = "show_extra_content"
+ const val SHOW_HIDDEN_FOLDERS = "show_hidden_folders"
+ 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"
+ const val USE_GENERATED_KEY = "use_generated_key"
+
+}
diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/TotpFinder.kt b/app/src/main/java/com/zeapo/pwdstore/utils/TotpFinder.kt
new file mode 100644
index 00000000..13a47543
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/utils/TotpFinder.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package com.zeapo.pwdstore.utils
+
+/**
+ * Defines a class that can extract relevant parts of a TOTP URL for use by the app.
+ */
+interface TotpFinder {
+
+ /**
+ * Get the TOTP secret from the given extra content.
+ */
+ fun findSecret(content: String): String?
+
+ /**
+ * Get the number of digits required in the final OTP.
+ */
+ fun findDigits(content: String): String
+
+ /**
+ * Get the TOTP timeout period.
+ */
+ fun findPeriod(content: String): Long
+
+ /**
+ * Get the algorithm for the TOTP secret.
+ */
+ fun findAlgorithm(content: String): String
+}
diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/UriTotpFinder.kt b/app/src/main/java/com/zeapo/pwdstore/utils/UriTotpFinder.kt
new file mode 100644
index 00000000..faa349d1
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/utils/UriTotpFinder.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package com.zeapo.pwdstore.utils
+
+import android.net.Uri
+
+/**
+ * [Uri] backed TOTP URL parser.
+ */
+class UriTotpFinder : TotpFinder {
+ override fun findSecret(content: String): String? {
+ content.split("\n".toRegex()).forEach { line ->
+ if (line.startsWith("otpauth://totp/")) {
+ return Uri.parse(line).getQueryParameter("secret")
+ }
+ if (line.startsWith("totp:", ignoreCase = true)) {
+ return line.split(": *".toRegex(), 2).toTypedArray()[1]
+ }
+ }
+ return null
+ }
+
+ override fun findDigits(content: String): String {
+ content.split("\n".toRegex()).forEach { line ->
+ if (line.startsWith("otpauth://totp/") &&
+ Uri.parse(line).getQueryParameter("digits") != null) {
+ return Uri.parse(line).getQueryParameter("digits")!!
+ }
+ }
+ return "6"
+ }
+
+ override fun findPeriod(content: String): Long {
+ content.split("\n".toRegex()).forEach { line ->
+ if (line.startsWith("otpauth://totp/") &&
+ Uri.parse(line).getQueryParameter("period") != null) {
+ val period = Uri.parse(line).getQueryParameter("period")!!.toLongOrNull()
+ if (period != null && period > 0)
+ return period
+ }
+ }
+ return 30
+ }
+
+ override fun findAlgorithm(content: String): String {
+ content.split("\n".toRegex()).forEach { line ->
+ if (line.startsWith("otpauth://totp/") &&
+ Uri.parse(line).getQueryParameter("algorithm") != null) {
+ return Uri.parse(line).getQueryParameter("algorithm")!!
+ }
+ }
+ return "sha1"
+ }
+}
diff --git a/app/src/main/res/drawable/ic_qr_code_scanner.xml b/app/src/main/res/drawable/ic_qr_code_scanner.xml
new file mode 100644
index 00000000..45a618ac
--- /dev/null
+++ b/app/src/main/res/drawable/ic_qr_code_scanner.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:pathData="M9.5,6.5v3h-3v-3H9.5M11,5H5v6h6V5L11,5zM9.5,14.5v3h-3v-3H9.5M11,13H5v6h6V13L11,13zM17.5,6.5v3h-3v-3H17.5M19,5h-6v6h6V5L19,5zM13,13h1.5v1.5H13V13zM14.5,14.5H16V16h-1.5V14.5zM16,13h1.5v1.5H16V13zM13,16h1.5v1.5H13V16zM14.5,17.5H16V19h-1.5V17.5zM16,16h1.5v1.5H16V16zM17.5,14.5H19V16h-1.5V14.5zM17.5,17.5H19V19h-1.5V17.5zM22,7h-2V4h-3V2h5V7zM22,22v-5h-2v3h-3v2H22zM2,22h5v-2H4v-3H2V22zM2,2v5h2V4h3V2H2z"
+ android:fillColor="#000000"/>
+</vector>
diff --git a/app/src/main/res/layout/fragment_ssh_keygen.xml b/app/src/main/res/layout/activity_ssh_keygen.xml
index 6ec3f2fb..6ec3f2fb 100644
--- a/app/src/main/res/layout/fragment_ssh_keygen.xml
+++ b/app/src/main/res/layout/activity_ssh_keygen.xml
diff --git a/app/src/main/res/layout/decrypt_layout.xml b/app/src/main/res/layout/decrypt_layout.xml
index 664cb482..6e2bf14c 100644
--- a/app/src/main/res/layout/decrypt_layout.xml
+++ b/app/src/main/res/layout/decrypt_layout.xml
@@ -10,7 +10,7 @@
android:layout_height="match_parent"
android:background="?android:attr/windowBackground"
android:orientation="vertical"
- tools:context="com.zeapo.pwdstore.crypto.PgpActivity">
+ tools:context="com.zeapo.pwdstore.crypto.DecryptActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="fill_parent"
@@ -91,6 +91,29 @@
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
+ android:id="@+id/otp_text_container"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="16dp"
+ android:hint="@string/otp"
+ android:visibility="gone"
+ app:endIconDrawable="@drawable/ic_content_copy"
+ app:endIconMode="custom"
+ app:layout_constraintTop_toBottomOf="@id/password_text_container"
+ tools:visibility="visible">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/otp_text"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:editable="false"
+ android:fontFamily="@font/sourcecodepro"
+ android:textIsSelectable="true"
+ tools:text="123456" />
+
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <com.google.android.material.textfield.TextInputLayout
android:id="@+id/username_text_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -99,7 +122,7 @@
android:visibility="gone"
app:endIconDrawable="@drawable/ic_content_copy"
app:endIconMode="custom"
- app:layout_constraintTop_toBottomOf="@id/password_text_container"
+ app:layout_constraintTop_toBottomOf="@id/otp_text_container"
tools:visibility="visible">
<com.google.android.material.textfield.TextInputEditText
diff --git a/app/src/main/res/layout/password_creation_activity.xml b/app/src/main/res/layout/password_creation_activity.xml
index 13af597c..e0b25786 100644
--- a/app/src/main/res/layout/password_creation_activity.xml
+++ b/app/src/main/res/layout/password_creation_activity.xml
@@ -84,6 +84,17 @@
</com.google.android.material.textfield.TextInputLayout>
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/otp_import_button"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="8dp"
+ android:text="@string/add_otp"
+ app:icon="@drawable/ic_qr_code_scanner"
+ app:iconTint="?attr/colorOnSecondary"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/extra_input_layout" />
+
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/encrypt_username"
android:layout_width="match_parent"
@@ -92,6 +103,6 @@
android:text="@string/crypto_encrypt_username_label"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/extra_input_layout"
+ app:layout_constraintTop_toBottomOf="@id/otp_import_button"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/password_row_layout.xml b/app/src/main/res/layout/password_row_layout.xml
index 30d9c40f..93da710e 100644
--- a/app/src/main/res/layout/password_row_layout.xml
+++ b/app/src/main/res/layout/password_row_layout.xml
@@ -26,12 +26,13 @@
<TextView
android:id="@+id/label"
- android:layout_width="wrap_content"
+ android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:textSize="18sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/type_image"
+ app:layout_constraintEnd_toStartOf="@id/child_count"
app:layout_constraintTop_toTopOf="parent"
tools:text="FILE_NAME" />
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 558b7a26..4eecf660 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -194,7 +194,6 @@
<string name="git_push_nff_error">La subida fue rechazada por el servidor, Ejecuta \'Descargar desde servidor\' antes de subir o pulsa \'Sincronizar con servidor\' para realizar ambas acciones.</string>
<string name="git_push_generic_error">El envío fue rechazado por el servidor, la razón:</string>
<string name="jgit_error_push_dialog_text">Ocurrió un error durante el envío:</string>
- <string name="hotp_remember_clear_choice">Limpiar preferencia para incremento HOTP</string>
<string name="git_operation_remember_passphrase">Recordar contraseñagit (inseguro)</string>
<string name="hackish_tools">Hackish tools</string>
<string name="abort_rebase">Abortar rebase</string>
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 76edb963..c4c75b55 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -194,7 +194,6 @@
<string name="git_push_generic_error">Poussée rejetée par le dépôt distant, raison:</string>
<string name="git_push_other_error">Pousser au dépôt distant sans avance rapide rejetée. Vérifiez la variable receive.denyNonFastForwards dans le fichier de configuration du répertoire de destination.</string>
<string name="jgit_error_push_dialog_text">Une erreur s\'est produite lors de l\'opération de poussée:</string>
- <string name="hotp_remember_clear_choice">Effacer les préférences enregistrées pour l’incrémentation HOTP</string>
<string name="git_operation_remember_passphrase">Se rappeler de la phrase secrète dans la configuration de l\'application (peu sûr)</string>
<string name="hackish_tools">Outils de hack</string>
<string name="commit_hash">Commettre la clé</string>
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 99217355..ff36585b 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -253,7 +253,6 @@
<string name="git_push_generic_error">Запись изменений была отклонена удаленным репозиторием, причина:</string>
<string name="git_push_other_error">Удаленный репозиторий отклонил запись изменений без быстрой перемотки вперед. Проверьте переменную receive.denyNonFastForwards в файле конфигурации репозитория назначения.</string>
<string name="jgit_error_push_dialog_text">В хоте операции записи изменений возникла ошибка:</string>
- <string name="hotp_remember_clear_choice">Очистить сохраненные настройки для увеличения HOTP</string>
<string name="git_operation_remember_passphrase">Заполнить парольную фразу в конфигурации приложнеия (небезопасно)</string>
<string name="hackish_tools">Костыльные инструменты</string>
<string name="abort_rebase">Прервать перебазирование и записать изменения в новую ветку</string>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c4f38a9f..3023d995 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -54,6 +54,7 @@
<string name="clipboard_password_toast_text">Password copied to clipboard, you have %d seconds to paste it somewhere.</string>
<string name="clipboard_password_no_clear_toast_text">Password copied to clipboard</string>
<string name="clipboard_copied_text">Copied to clipboard</string>
+ <string name="clipboard_otp_copied_text">OTP code copied to clipboard</string>
<string name="file_toast_text">Please provide a file name</string>
<string name="path_toast_text">Please provide a file path</string>
<string name="empty_toast_text">You cannot use an empty password or empty extra content</string>
@@ -111,6 +112,7 @@
<!-- DECRYPT Layout -->
<string name="action_search">Search</string>
<string name="password">Password:</string>
+ <string name="otp">OTP:</string>
<string name="extra_content">Extra content:</string>
<string name="username">Username:</string>
<string name="edit_password">Edit password</string>
@@ -118,6 +120,7 @@
<string name="copy_username">Copy username</string>
<string name="share_as_plaintext">Share as plaintext</string>
<string name="last_changed">Last changed %s</string>
+ <string name="view_otp">View OTP</string>
<!-- Preferences -->
<string name="pref_repository_title">Repository</string>
@@ -297,7 +300,6 @@
<string name="jgit_error_push_dialog_text">Error occurred during the push operation:</string>
<string name="clear_saved_passphrase_ssh">Clear saved passphrase for local SSH key</string>
<string name="clear_saved_passphrase_https">Clear saved HTTPS password</string>
- <string name="hotp_remember_clear_choice">Clear saved preference for HOTP incrementing</string>
<string name="git_operation_remember_passphrase">Remember key passphrase</string>
<string name="hackish_tools">Hackish tools</string>
<string name="abort_rebase">Abort rebase and push new branch</string>
@@ -381,4 +383,7 @@
<string name="password_creation_file_write_fail_message">Failed to write password file to the store, please try again.</string>
<string name="password_creation_file_delete_fail_message">Failed to delete password file %1$s from the store, please delete it manually.</string>
<string name="password_creation_duplicate_error">File already exists, please use a different name</string>
+ <string name="add_otp">Add OTP</string>
+ <string name="otp_import_success">Successfully imported TOTP configuration</string>
+ <string name="otp_import_failure">Failed to import TOTP configuration</string>
</resources>
diff --git a/app/src/test/java/com/zeapo/pwdstore/PasswordEntryTest.kt b/app/src/test/java/com/zeapo/pwdstore/PasswordEntryTest.kt
deleted file mode 100644
index 2074f40b..00000000
--- a/app/src/test/java/com/zeapo/pwdstore/PasswordEntryTest.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package com.zeapo.pwdstore
-
-import org.junit.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertFalse
-import kotlin.test.assertNull
-import kotlin.test.assertTrue
-
-class PasswordEntryTest {
- @Test fun testGetPassword() {
- assertEquals("fooooo", PasswordEntry("fooooo\nbla\n").password)
- assertEquals("fooooo", PasswordEntry("fooooo\nbla").password)
- assertEquals("fooooo", PasswordEntry("fooooo\n").password)
- assertEquals("fooooo", PasswordEntry("fooooo").password)
- assertEquals("", PasswordEntry("\nblubb\n").password)
- assertEquals("", PasswordEntry("\nblubb").password)
- assertEquals("", PasswordEntry("\n").password)
- assertEquals("", PasswordEntry("").password)
- }
-
- @Test fun testGetExtraContent() {
- assertEquals("bla\n", PasswordEntry("fooooo\nbla\n").extraContent)
- assertEquals("bla", PasswordEntry("fooooo\nbla").extraContent)
- assertEquals("", PasswordEntry("fooooo\n").extraContent)
- assertEquals("", PasswordEntry("fooooo").extraContent)
- assertEquals("blubb\n", PasswordEntry("\nblubb\n").extraContent)
- assertEquals("blubb", PasswordEntry("\nblubb").extraContent)
- assertEquals("", PasswordEntry("\n").extraContent)
- assertEquals("", PasswordEntry("").extraContent)
- }
-
- @Test fun testGetUsername() {
- for (field in PasswordEntry.USERNAME_FIELDS) {
- assertEquals("username", PasswordEntry("\n$field username").username)
- assertEquals("username", PasswordEntry("\n${field.toUpperCase()} username").username)
- }
- assertEquals(
- "username",
- PasswordEntry("secret\nextra\nlogin: username\ncontent\n").username)
- assertEquals(
- "username",
- PasswordEntry("\nextra\nusername: username\ncontent\n").username)
- assertEquals(
- "username", PasswordEntry("\nUSERNaMe: username\ncontent\n").username)
- assertEquals("username", PasswordEntry("\nlogin: username").username)
- assertEquals("foo@example.com", PasswordEntry("\nemail: foo@example.com").username)
- assertEquals("username", PasswordEntry("\nidentity: username\nlogin: another_username").username)
- assertEquals("username", PasswordEntry("\nLOGiN:username").username)
- assertNull(PasswordEntry("secret\nextra\ncontent\n").username)
- }
-
- @Test fun testHasUsername() {
- assertTrue(PasswordEntry("secret\nextra\nlogin: username\ncontent\n").hasUsername())
- assertFalse(PasswordEntry("secret\nextra\ncontent\n").hasUsername())
- assertFalse(PasswordEntry("secret\nlogin failed\n").hasUsername())
- assertFalse(PasswordEntry("\n").hasUsername())
- assertFalse(PasswordEntry("").hasUsername())
- }
-}
diff --git a/app/src/test/java/com/zeapo/pwdstore/model/PasswordEntryTest.kt b/app/src/test/java/com/zeapo/pwdstore/model/PasswordEntryTest.kt
new file mode 100644
index 00000000..f31709df
--- /dev/null
+++ b/app/src/test/java/com/zeapo/pwdstore/model/PasswordEntryTest.kt
@@ -0,0 +1,107 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package com.zeapo.pwdstore.model
+
+import com.zeapo.pwdstore.utils.Otp
+import com.zeapo.pwdstore.utils.TotpFinder
+import org.junit.Test
+import java.util.Date
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class PasswordEntryTest {
+ private fun makeEntry(content: String) = PasswordEntry(content, testFinder)
+
+ @Test fun testGetPassword() {
+ assertEquals("fooooo", makeEntry("fooooo\nbla\n").password)
+ assertEquals("fooooo", makeEntry("fooooo\nbla").password)
+ assertEquals("fooooo", makeEntry("fooooo\n").password)
+ assertEquals("fooooo", makeEntry("fooooo").password)
+ assertEquals("", makeEntry("\nblubb\n").password)
+ assertEquals("", makeEntry("\nblubb").password)
+ assertEquals("", makeEntry("\n").password)
+ assertEquals("", makeEntry("").password)
+ }
+
+ @Test fun testGetExtraContent() {
+ assertEquals("bla\n", makeEntry("fooooo\nbla\n").extraContent)
+ assertEquals("bla", makeEntry("fooooo\nbla").extraContent)
+ assertEquals("", makeEntry("fooooo\n").extraContent)
+ assertEquals("", makeEntry("fooooo").extraContent)
+ assertEquals("blubb\n", makeEntry("\nblubb\n").extraContent)
+ assertEquals("blubb", makeEntry("\nblubb").extraContent)
+ assertEquals("", makeEntry("\n").extraContent)
+ assertEquals("", makeEntry("").extraContent)
+ }
+
+ @Test fun testGetUsername() {
+ for (field in PasswordEntry.USERNAME_FIELDS) {
+ assertEquals("username", makeEntry("\n$field username").username)
+ assertEquals("username", makeEntry("\n${field.toUpperCase()} username").username)
+ }
+ assertEquals(
+ "username",
+ makeEntry("secret\nextra\nlogin: username\ncontent\n").username)
+ assertEquals(
+ "username",
+ makeEntry("\nextra\nusername: username\ncontent\n").username)
+ assertEquals(
+ "username", makeEntry("\nUSERNaMe: username\ncontent\n").username)
+ assertEquals("username", makeEntry("\nlogin: username").username)
+ assertEquals("foo@example.com", makeEntry("\nemail: foo@example.com").username)
+ assertEquals("username", makeEntry("\nidentity: username\nlogin: another_username").username)
+ assertEquals("username", makeEntry("\nLOGiN:username").username)
+ assertNull(makeEntry("secret\nextra\ncontent\n").username)
+ }
+
+ @Test fun testHasUsername() {
+ assertTrue(makeEntry("secret\nextra\nlogin: username\ncontent\n").hasUsername())
+ assertFalse(makeEntry("secret\nextra\ncontent\n").hasUsername())
+ assertFalse(makeEntry("secret\nlogin failed\n").hasUsername())
+ assertFalse(makeEntry("\n").hasUsername())
+ assertFalse(makeEntry("").hasUsername())
+ }
+
+ @Test fun testGeneratesOtpFromTotpUri() {
+ val entry = makeEntry("secret\nextra\n$TOTP_URI")
+ assertTrue(entry.hasTotp())
+ val code = Otp.calculateCode(
+ entry.totpSecret!!,
+ // The hardcoded date value allows this test to stay reproducible.
+ Date(8640000).time / (1000 * entry.totpPeriod),
+ entry.totpAlgorithm,
+ entry.digits
+ )
+ assertNotNull(code) { "Generated OTP cannot be null" }
+ assertEquals(entry.digits.toInt(), code.length)
+ assertEquals("545293", code)
+ }
+
+ companion object {
+ const val TOTP_URI = "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30"
+
+ // This implementation is hardcoded for the URI above.
+ val testFinder = object : TotpFinder {
+ override fun findSecret(content: String): String? {
+ return "HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ"
+ }
+
+ override fun findDigits(content: String): String {
+ return "6"
+ }
+
+ override fun findPeriod(content: String): Long {
+ return 30
+ }
+
+ override fun findAlgorithm(content: String): String {
+ return "SHA1"
+ }
+ }
+ }
+}
diff --git a/app/src/test/java/com/zeapo/pwdstore/utils/OtpTest.kt b/app/src/test/java/com/zeapo/pwdstore/utils/OtpTest.kt
new file mode 100644
index 00000000..710b0845
--- /dev/null
+++ b/app/src/test/java/com/zeapo/pwdstore/utils/OtpTest.kt
@@ -0,0 +1,50 @@
+package com.zeapo.pwdstore.utils
+
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+class OtpTest {
+
+ @Test
+ fun testOtpGeneration6Digits() {
+ assertEquals("953550", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333298159 / (1000 * 30), "SHA1", "6"))
+ assertEquals("275379", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333571918 / (1000 * 30), "SHA1", "6"))
+ assertEquals("867507", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333600517 / (1000 * 57), "SHA1", "6"))
+ }
+
+ @Test
+ fun testOtpGeneration10Digits() {
+ assertEquals("0740900914", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333655044 / (1000 * 30), "SHA1", "10"))
+ assertEquals("0070632029", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333691405 / (1000 * 30), "SHA1", "10"))
+ assertEquals("1017265882", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333728893 / (1000 * 83), "SHA1", "10"))
+ }
+
+ @Test
+ fun testOtpGenerationIllegalInput() {
+ assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA0", "10"))
+ assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "a"))
+ assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "5"))
+ assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "11"))
+ assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAB", 10000, "SHA1", "6"))
+ }
+
+ @Test
+ fun testOtpGenerationUnusualSecrets() {
+ assertEquals("127764", Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAAAAA", 1593367111963 / (1000 * 30), "SHA1", "6"))
+ assertEquals("047515", Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAA", 1593367171420 / (1000 * 30), "SHA1", "6"))
+ }
+
+ @Test
+ fun testOtpGenerationUnpaddedSecrets() {
+ // Secret was generated with `echo 'string with some padding needed' | base32`
+ // We don't care for the resultant OTP's actual value, we just want both the padded and
+ // unpadded variant to generate the same one.
+ val unpaddedOtp = Otp.calculateCode("ON2HE2LOM4QHO2LUNAQHG33NMUQHAYLEMRUW4ZZANZSWKZDFMQFA", 1593367171420 / (1000 * 30), "SHA1", "6")
+ val paddedOtp = Otp.calculateCode("ON2HE2LOM4QHO2LUNAQHG33NMUQHAYLEMRUW4ZZANZSWKZDFMQFA====", 1593367171420 / (1000 * 30), "SHA1", "6")
+ assertNotNull(unpaddedOtp)
+ assertNotNull(paddedOtp)
+ assertEquals(unpaddedOtp, paddedOtp)
+ }
+}