diff options
39 files changed, 746 insertions, 812 deletions
diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 8306d009..e6e600fb 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -7,7 +7,7 @@ jobs: strategy: matrix: api-level: [23, 29] - variant: [freeRelease, nonFreeRelease] + variant: [freeDebug, nonFreeDebug] steps: - name: Check if relevant files have changed @@ -70,7 +70,7 @@ jobs: run: ./gradlew test${{ matrix.variant }} lint${{ matrix.variant}} -Dpre-dex=false - name: Run instrumentation tests on free flavor - if: ${{ steps.service-changed.outputs.result == 'true' && matrix.variant == 'freeRelease' }} + if: ${{ steps.service-changed.outputs.result == 'true' && matrix.variant == 'freeDebug' }} uses: reactivecircus/android-emulator-runner@v2.11.0 with: api-level: ${{ matrix.api-level }} @@ -82,7 +82,7 @@ jobs: ./gradlew :app:connectedFreeDebugAndroidTest - name: Run instrumentation tests on nonFree flavor - if: ${{ steps.service-changed.outputs.result == 'true' && matrix.variant == 'nonFreeRelease' }} + if: ${{ steps.service-changed.outputs.result == 'true' && matrix.variant == 'nonFreeDebug' }} uses: reactivecircus/android-emulator-runner@v2.11.0 with: api-level: ${{ matrix.api-level }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c389dfd..a0067ecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Allow sorting by recently used +- Add [Bromite](https://www.bromite.org/) and [Ungoogled Chromium](https://git.droidware.info/wchen342/ungoogled-chromium-android) to supported browsers list for Autofill + +### Changed + +- A descriptive error message is shown if no username is specified in the Git server settings +- Remove explicit protocol choice from Git server settings, it is now inferred from your URL +- 'Show hidden folders' is now 'Show hidden files and folders' + +### Fixed + +- Password creation UI will scroll if it does not fit on the screen +- Git server protocol and authentication mode are only updated when explicitly saved +- Remember HTTPS password during a sync operation + ## [1.11.3] - 2020-08-27 ### Fixed diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f9f8e3b6..c4a17e43 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,8 +30,8 @@ android { defaultConfig { applicationId = "dev.msfjarvis.aps" - versionCode = 11130 - versionName = "1.11.3" + versionCode = 11131 + versionName = "1.12.0-SNAPSHOT" } lintOptions { diff --git a/app/src/androidTest/java/com/zeapo/pwdstore/MigrationsTest.kt b/app/src/androidTest/java/com/zeapo/pwdstore/MigrationsTest.kt index d1b04fc3..5ba1b307 100644 --- a/app/src/androidTest/java/com/zeapo/pwdstore/MigrationsTest.kt +++ b/app/src/androidTest/java/com/zeapo/pwdstore/MigrationsTest.kt @@ -4,17 +4,20 @@ */ @file:Suppress("DEPRECATION") + package com.zeapo.pwdstore import android.content.Context import androidx.core.content.edit +import com.zeapo.pwdstore.git.config.AuthMode +import com.zeapo.pwdstore.git.config.Protocol import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.getString import com.zeapo.pwdstore.utils.sharedPrefs +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test -import org.junit.Assert.* - class MigrationsTest { private fun checkOldKeysAreRemoved(context: Context) = with(context.sharedPrefs) { @@ -22,6 +25,7 @@ class MigrationsTest { assertNull(getString(PreferenceKeys.GIT_REMOTE_USERNAME)) assertNull(getString(PreferenceKeys.GIT_REMOTE_SERVER)) assertNull(getString(PreferenceKeys.GIT_REMOTE_LOCATION)) + assertNull(getString(PreferenceKeys.GIT_REMOTE_PROTOCOL)) } @Test @@ -33,7 +37,8 @@ class MigrationsTest { putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis") putString(PreferenceKeys.GIT_REMOTE_LOCATION, "/mnt/disk3/pass-repo") putString(PreferenceKeys.GIT_REMOTE_SERVER, "192.168.0.102") - putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, "ssh://") + putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Ssh.pref) + putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.Password.pref) } runMigrations(context) checkOldKeysAreRemoved(context) @@ -51,7 +56,8 @@ class MigrationsTest { putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis") putString(PreferenceKeys.GIT_REMOTE_LOCATION, "/mnt/disk3/pass-repo") putString(PreferenceKeys.GIT_REMOTE_SERVER, "192.168.0.102") - putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, "ssh://") + putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Ssh.pref) + putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.SshKey.pref) } runMigrations(context) checkOldKeysAreRemoved(context) @@ -69,7 +75,8 @@ class MigrationsTest { putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis") putString(PreferenceKeys.GIT_REMOTE_LOCATION, "Android-Password-Store/pass-test") putString(PreferenceKeys.GIT_REMOTE_SERVER, "github.com") - putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, "https://") + putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Https.pref) + putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.None.pref) } runMigrations(context) checkOldKeysAreRemoved(context) @@ -78,4 +85,25 @@ class MigrationsTest { "https://github.com/Android-Password-Store/pass-test" ) } + + @Test + fun verifyHiddenFoldersMigrationIfDisabled() { + val context = Application.instance.applicationContext + context.sharedPrefs.edit { clear() } + runMigrations(context) + assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, true)) + assertEquals(false, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false)) + } + + @Test + fun verifyHiddenFoldersMigrationIfEnabled() { + val context = Application.instance.applicationContext + context.sharedPrefs.edit { + clear() + putBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, true) + } + runMigrations(context) + assertEquals(false, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false)) + assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false)) + } } diff --git a/app/src/main/java/com/zeapo/pwdstore/Application.kt b/app/src/main/java/com/zeapo/pwdstore/Application.kt index 544eb047..3108dee3 100644 --- a/app/src/main/java/com/zeapo/pwdstore/Application.kt +++ b/app/src/main/java/com/zeapo/pwdstore/Application.kt @@ -12,7 +12,7 @@ import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES import com.github.ajalt.timberkt.Timber.DebugTree import com.github.ajalt.timberkt.Timber.plant -import com.zeapo.pwdstore.git.config.setUpBouncyCastleForSshj +import com.zeapo.pwdstore.git.sshj.setUpBouncyCastleForSshj import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.sharedPrefs import com.zeapo.pwdstore.utils.getString diff --git a/app/src/main/java/com/zeapo/pwdstore/Migrations.kt b/app/src/main/java/com/zeapo/pwdstore/Migrations.kt index 1e1943d8..f7cce784 100644 --- a/app/src/main/java/com/zeapo/pwdstore/Migrations.kt +++ b/app/src/main/java/com/zeapo/pwdstore/Migrations.kt @@ -19,6 +19,7 @@ import java.net.URI fun runMigrations(context: Context) { migrateToGitUrlBasedConfig(context) + migrateToHideAll(context) } private fun migrateToGitUrlBasedConfig(context: Context) { @@ -75,9 +76,21 @@ private fun migrateToGitUrlBasedConfig(context: Context) { remove(PreferenceKeys.GIT_REMOTE_PORT) remove(PreferenceKeys.GIT_REMOTE_SERVER) remove(PreferenceKeys.GIT_REMOTE_USERNAME) + remove(PreferenceKeys.GIT_REMOTE_PROTOCOL) } - if (url == null || !GitSettings.updateUrlIfValid(url)) { + if (url == null || GitSettings.updateConnectionSettingsIfValid( + newAuthMode = GitSettings.authMode, + newUrl = url, + newBranch = GitSettings.branch) != GitSettings.UpdateConnectionSettingsResult.Valid) { e { "Failed to migrate to URL-based Git config, generated URL is invalid" } } } +private fun migrateToHideAll(context: Context) { + context.sharedPrefs.all[PreferenceKeys.SHOW_HIDDEN_FOLDERS] ?: return + val isHidden = context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false) + context.sharedPrefs.edit { + remove(PreferenceKeys.SHOW_HIDDEN_FOLDERS) + putBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, isHidden) + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt index 6657afaf..7e0ef418 100644 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt +++ b/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt @@ -16,6 +16,7 @@ import android.view.animation.Animation import android.view.animation.AnimationUtils import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.appcompat.view.ActionMode +import androidx.core.content.edit import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.DividerItemDecoration @@ -25,13 +26,16 @@ import com.zeapo.pwdstore.databinding.PasswordRecyclerViewBinding import com.zeapo.pwdstore.git.BaseGitActivity import com.zeapo.pwdstore.git.GitOperationActivity import com.zeapo.pwdstore.git.GitServerConfigActivity -import com.zeapo.pwdstore.git.config.ConnectionMode +import com.zeapo.pwdstore.git.config.AuthMode import com.zeapo.pwdstore.git.config.GitSettings import com.zeapo.pwdstore.ui.OnOffItemAnimator 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.base64 +import com.zeapo.pwdstore.utils.getString import com.zeapo.pwdstore.utils.sharedPrefs import com.zeapo.pwdstore.utils.viewBinding import java.io.File @@ -89,10 +93,10 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) { .show() binding.swipeRefresher.isRefreshing = false } 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 (GitSettings.connectionMode) { - ConnectionMode.None -> BaseGitActivity.REQUEST_PULL + // When authentication is set to AuthMode.None then the only git operation we can + // run is a pull, so automatically fallback to that. + val operationId = when (GitSettings.authMode) { + AuthMode.None -> BaseGitActivity.REQUEST_PULL else -> BaseGitActivity.REQUEST_SYNC } val intent = Intent(context, GitOperationActivity::class.java) @@ -243,6 +247,14 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) { try { listener = object : OnFragmentInteractionListener { override fun onFragmentInteraction(item: PasswordItem) { + if (settings.getString(PreferenceKeys.SORT_ORDER) == PasswordRepository.PasswordSortOrder.RECENTLY_USED.name) { + //save the time when password was used + val preferences = context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) + preferences.edit { + putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString()) + } + } + if (item.type == PasswordItem.TYPE_CATEGORY) { navigateTo(item.file) } else { diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt index 3ebd35b8..d9afc7c9 100644 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt +++ b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt @@ -6,6 +6,7 @@ package com.zeapo.pwdstore import android.Manifest import android.annotation.SuppressLint +import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ShortcutInfo.Builder @@ -49,7 +50,7 @@ import com.zeapo.pwdstore.crypto.PasswordCreationActivity import com.zeapo.pwdstore.git.BaseGitActivity import com.zeapo.pwdstore.git.GitOperationActivity import com.zeapo.pwdstore.git.GitServerConfigActivity -import com.zeapo.pwdstore.git.config.ConnectionMode +import com.zeapo.pwdstore.git.config.AuthMode import com.zeapo.pwdstore.git.config.GitSettings import com.zeapo.pwdstore.ui.dialogs.FolderCreationDialogFragment import com.zeapo.pwdstore.utils.PasswordItem @@ -63,6 +64,7 @@ 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.base64 import com.zeapo.pwdstore.utils.commitChange import com.zeapo.pwdstore.utils.contains import com.zeapo.pwdstore.utils.getString @@ -247,7 +249,7 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { override fun onCreateOptionsMenu(menu: Menu?): Boolean { val menuRes = when { - GitSettings.connectionMode == ConnectionMode.None -> R.menu.main_menu_no_auth + GitSettings.authMode == AuthMode.None -> R.menu.main_menu_no_auth PasswordRepository.isGitRepo() -> R.menu.main_menu_git else -> R.menu.main_menu_non_git } @@ -753,6 +755,17 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { !newCategory.isInsideRepository() -> renameCategory(oldCategory, CategoryRenameError.DestinationOutsideRepo) else -> lifecycleScope.launch(Dispatchers.IO) { moveFile(oldCategory.file, newCategory) + + //associate the new category with the last category's timestamp in history + val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) + val timestamp = preference.getString(oldCategory.file.absolutePath.base64()) + if (timestamp != null) { + preference.edit { + remove(oldCategory.file.absolutePath.base64()) + putString(newCategory.absolutePath.base64(), timestamp) + } + } + withContext(Dispatchers.Main) { commitChange( resources.getString(R.string.git_commit_move_text, oldCategory.name, newCategory.name), diff --git a/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt b/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt index 7ffa71a3..6da5de7a 100644 --- a/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt +++ b/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt @@ -94,8 +94,9 @@ private fun PasswordItem.Companion.makeComparator( PasswordRepository.PasswordSortOrder.FOLDER_FIRST -> compareBy { it.type } // In order to let INDEPENDENT not distinguish between items based on their type, we simply // declare them all equal at this stage. - PasswordRepository.PasswordSortOrder.INDEPENDENT -> Comparator<PasswordItem> { _, _ -> 0 } + PasswordRepository.PasswordSortOrder.INDEPENDENT -> Comparator { _, _ -> 0 } PasswordRepository.PasswordSortOrder.FILE_FIRST -> compareByDescending { it.type } + PasswordRepository.PasswordSortOrder.RECENTLY_USED -> PasswordRepository.PasswordSortOrder.RECENTLY_USED.comparator } .then(compareBy(nullsLast(CaseInsensitiveComparator)) { directoryStructure.getIdentifierFor(it.file) @@ -139,8 +140,8 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel private val root get() = PasswordRepository.getRepositoryDirectory() private val settings by lazy { application.sharedPrefs } - private val showHiddenDirs - get() = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false) + private val showHiddenContents + get() = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false) private val defaultSearchMode get() = if (settings.getBoolean(PreferenceKeys.FILTER_RECURSIVELY, true)) { SearchMode.RecursivelyInSubdirectories @@ -253,8 +254,9 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel }.asLiveData(Dispatchers.IO) private fun shouldTake(file: File) = with(file) { + if (showHiddenContents) return true if (isDirectory) { - !isHidden || showHiddenDirs + !isHidden } else { !isHidden && file.extension == "gpg" } diff --git a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt index 3b73d6e5..8f306f6b 100644 --- a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt +++ b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt @@ -279,7 +279,8 @@ class UserPreference : AppCompatActivity() { findPreference<CheckBoxPreference>(PreferenceKeys.ENABLE_DEBUG_LOGGING)?.isVisible = !BuildConfig.ENABLE_DEBUG_FEATURES findPreference<CheckBoxPreference>(PreferenceKeys.BIOMETRIC_AUTH)?.apply { - val isFingerprintSupported = BiometricManager.from(requireContext()).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS + val isFingerprintSupported = BiometricManager.from(requireContext()) + .canAuthenticate(BiometricManager.Authenticators.DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS if (!isFingerprintSupported) { isEnabled = false isChecked = false diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt index 5c8b6b3b..38bdd068 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt @@ -65,6 +65,7 @@ private val TRUSTED_BROWSER_CERTIFICATE_HASH = mapOf( "com.opera.mini.native" to "V6y8Ul8bLr0ZGWzW8BQ5fMkQ/RiEHgroUP68Ph5ZP/I=", "com.opera.mini.native.beta" to "V6y8Ul8bLr0ZGWzW8BQ5fMkQ/RiEHgroUP68Ph5ZP/I=", "com.opera.touch" to "qtjiBNJNF3k0yc0MY8xqo4779CxKaVcJfiIQ9X+qZ6o=", + "org.bromite.bromite" to "4e5c0HbXsNyEyytF+3i4bfLrOaO2xWuj3CkqXgw7lQQ=", "org.gnu.icecat" to "wi2iuVvK/WYZUzd2g0Qzn9ef3kAisQURZ8U1WSMTkcM=", "org.mozilla.fenix" to "UAR3kIjn+YjVvFzF+HmP6/T4zQhKGypG79TI7krq8hE=", "org.mozilla.fenix.nightly" to "d+rEzu02r++6dheZMd1MwZWrDNVLrzVdIV57vdKOQCo=", @@ -75,6 +76,8 @@ private val TRUSTED_BROWSER_CERTIFICATE_HASH = mapOf( "org.mozilla.focus" to "YgOkc7421k7jf4f6UA7bx56rkwYQq5ufpMp9XB8bT/w=", "org.mozilla.klar" to "YgOkc7421k7jf4f6UA7bx56rkwYQq5ufpMp9XB8bT/w=", "org.torproject.torbrowser" to "IAYfBF5zfGc3XBd5TP7bQ2oDzsa6y3y5+WZCIFyizsg=", + "org.ungoogled.chromium.stable" to "29UOO5cXoxO/e/hH3hOu6bbtg1My4tK6Eik2Ym5Krtk=", + "org.ungoogled.chromium.extensions.stable" to "29UOO5cXoxO/e/hH3hOu6bbtg1My4tK6Eik2Ym5Krtk=", ) private fun isTrustedBrowser(context: Context, appPackage: String): Boolean { @@ -163,6 +166,8 @@ private val FLAKY_BROWSERS = listOf( "com.chrome.beta", "com.chrome.canary", "com.chrome.dev", + "org.bromite.bromite", + "org.ungoogled.chromium.stable", ) enum class BrowserAutofillSupportLevel { 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 44376c0a..ff5a8179 100644 --- a/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt @@ -5,6 +5,7 @@ package com.zeapo.pwdstore.crypto +import android.content.Context import android.content.Intent import android.os.Bundle import android.text.InputType @@ -14,6 +15,7 @@ import android.view.View import androidx.activity.result.IntentSenderRequest import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult +import androidx.core.content.edit import androidx.core.view.isVisible import androidx.core.widget.doOnTextChanged import androidx.lifecycle.lifecycleScope @@ -30,6 +32,7 @@ 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.base64 import com.zeapo.pwdstore.utils.commitChange import com.zeapo.pwdstore.utils.getString import com.zeapo.pwdstore.utils.isInsideRepository @@ -411,6 +414,17 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB return@executeApiAsync } + //associate the new password name with the last name's timestamp in history + val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) + val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg".base64() + val timestamp = preference.getString(oldFilePathHash) + if (timestamp != null) { + preference.edit { + remove(oldFilePathHash) + putString(file.absolutePath.base64(), timestamp) + } + } + val returnIntent = Intent() returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path) returnIntent.putExtra(RETURN_EXTRA_NAME, editName) 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 fb0831d6..ed72c88d 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt @@ -4,17 +4,12 @@ */ package com.zeapo.pwdstore.git -import android.content.Intent import android.view.MenuItem -import androidx.annotation.CallSuper import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.lifecycleScope import com.github.ajalt.timberkt.Timber.tag import com.github.ajalt.timberkt.e import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.git.config.ConnectionMode import com.zeapo.pwdstore.git.config.GitSettings -import com.zeapo.pwdstore.git.config.SshApiSessionFactory import com.zeapo.pwdstore.git.operation.BreakOutOfDetached import com.zeapo.pwdstore.git.operation.CloneOperation import com.zeapo.pwdstore.git.operation.GitOperation @@ -23,7 +18,6 @@ import com.zeapo.pwdstore.git.operation.PushOperation import com.zeapo.pwdstore.git.operation.ResetToRemoteOperation import com.zeapo.pwdstore.git.operation.SyncOperation import com.zeapo.pwdstore.utils.PasswordRepository -import kotlinx.coroutines.launch /** * Abstract AppCompatActivity that holds some information that is commonly shared across git-related @@ -31,9 +25,6 @@ import kotlinx.coroutines.launch */ abstract class BaseGitActivity : AppCompatActivity() { - private var identityBuilder: SshApiSessionFactory.IdentityBuilder? = null - private var identity: SshApiSessionFactory.ApiIdentity? = null - override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { android.R.id.home -> { @@ -44,23 +35,8 @@ abstract class BaseGitActivity : AppCompatActivity() { } } - @CallSuper - override fun onDestroy() { - // Do not leak the service connection - if (identityBuilder != null) { - identityBuilder!!.close() - identityBuilder = null - } - super.onDestroy() - } - /** - * Attempt to launch the requested Git operation. Depending on the configured auth, it may not - * be possible to launch the operation immediately. In that case, this function may launch an - * intermediate activity instead, which will gather necessary information and post it back via - * onActivityResult, which will then re-call this function. This may happen multiple times, - * until either an error is encountered or the operation is successfully launched. - * + * Attempt to launch the requested Git operation. * @param operation The type of git operation to launch */ suspend fun launchGitOperation(operation: Int) { @@ -70,21 +46,6 @@ abstract class BaseGitActivity : AppCompatActivity() { return } try { - // Before launching the operation with OpenKeychain auth, we need to issue several requests - // to the OpenKeychain API. IdentityBuild will take care of launching the relevant intents, - // we just need to keep calling it until it returns a completed ApiIdentity. - if (GitSettings.connectionMode == ConnectionMode.OpenKeychain && identity == null) { - // Lazy initialization of the IdentityBuilder - if (identityBuilder == null) { - identityBuilder = SshApiSessionFactory.IdentityBuilder(this) - } - // Try to get an ApiIdentity and bail if one is not ready yet. The builder will ensure - // that onActivityResult is called with operation again, which will re-invoke us here - identity = identityBuilder!!.tryBuild(operation) - if (identity == null) - return - } - val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory()) val op = when (operation) { REQUEST_CLONE, GitOperation.GET_SSH_KEY_FROM_CLONE -> CloneOperation(localDir, GitSettings.url!!, this) @@ -93,7 +54,6 @@ abstract class BaseGitActivity : AppCompatActivity() { REQUEST_SYNC -> SyncOperation(localDir, this) BREAK_OUT_OF_DETACHED -> BreakOutOfDetached(localDir, this) REQUEST_RESET -> ResetToRemoteOperation(localDir, this) - SshApiSessionFactory.POST_SIGNATURE -> return else -> { tag(TAG).e { "Operation not recognized : $operation" } setResult(RESULT_CANCELED) @@ -101,46 +61,13 @@ abstract class BaseGitActivity : AppCompatActivity() { return } } - op.executeAfterAuthentication(GitSettings.connectionMode, identity) + op.executeAfterAuthentication(GitSettings.authMode) } catch (e: Exception) { e.printStackTrace() MaterialAlertDialogBuilder(this).setMessage(e.message).show() } } - public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - // In addition to the pre-operation-launch series of intents for OpenKeychain auth - // that will pass through here and back to launchGitOperation, there is one - // synchronous operation that happens /after/ the operation has been launched in the - // background thread - the actual signing of the SSH challenge. We pass through the - // completed signature to the ApiIdentity, which will be blocked in the other thread - // waiting for it. - if (requestCode == SshApiSessionFactory.POST_SIGNATURE && identity != null) { - identity!!.postSignature(data) - - // If the signature failed (usually because it was cancelled), reset state - if (data == null) { - identity = null - identityBuilder = null - } - return - } - - if (resultCode == RESULT_CANCELED) { - setResult(RESULT_CANCELED) - finish() - } else if (resultCode == RESULT_OK) { - // If an operation has been re-queued via this mechanism, let the - // IdentityBuilder attempt to extract some updated state from the intent before - // trying to re-launch the operation. - if (identityBuilder != null) { - identityBuilder!!.consume(data) - } - lifecycleScope.launch { launchGitOperation(requestCode) } - } - super.onActivityResult(requestCode, resultCode, data) - } - companion object { const val REQUEST_ARG_OP = "OPERATION" diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt index f06fc891..bf35e5c7 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt @@ -14,7 +14,8 @@ import com.google.android.material.snackbar.Snackbar import com.zeapo.pwdstore.R import com.zeapo.pwdstore.git.GitException.PullException import com.zeapo.pwdstore.git.GitException.PushException -import com.zeapo.pwdstore.git.config.SshjSessionFactory +import com.zeapo.pwdstore.git.config.GitSettings +import com.zeapo.pwdstore.git.sshj.SshjSessionFactory import com.zeapo.pwdstore.git.operation.GitOperation import com.zeapo.pwdstore.utils.Result import com.zeapo.pwdstore.utils.snackbar @@ -28,6 +29,7 @@ import org.eclipse.jgit.api.PullCommand import org.eclipse.jgit.api.PushCommand import org.eclipse.jgit.api.RebaseResult import org.eclipse.jgit.api.StatusCommand +import org.eclipse.jgit.lib.PersonIdent import org.eclipse.jgit.transport.RemoteRefUpdate import org.eclipse.jgit.transport.SshSessionFactory @@ -60,7 +62,9 @@ class GitCommandExecutor( // the previous status will eventually be used to avoid a commit if (nbChanges > 0) { withContext(Dispatchers.IO) { - command.call() + command + .setAuthor(PersonIdent(GitSettings.authorName, GitSettings.authorEmail)) + .call() } } } 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 5138b50b..9205e7fc 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt @@ -61,8 +61,6 @@ class GitConfigActivity : BaseGitActivity() { } else { GitSettings.authorEmail = email GitSettings.authorName = name - PasswordRepository.setGitAuthorEmail(email) - PasswordRepository.setGitAuthorName(name) Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show() Handler().postDelayed(500) { finish() } } 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 82e97e17..5aa34201 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt @@ -13,7 +13,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.zeapo.pwdstore.R import com.zeapo.pwdstore.databinding.ActivityGitCloneBinding -import com.zeapo.pwdstore.git.config.ConnectionMode +import com.zeapo.pwdstore.git.config.AuthMode import com.zeapo.pwdstore.git.config.GitSettings import com.zeapo.pwdstore.git.config.Protocol import com.zeapo.pwdstore.utils.PasswordRepository @@ -32,6 +32,8 @@ class GitServerConfigActivity : BaseGitActivity() { private val binding by viewBinding(ActivityGitCloneBinding::inflate) + private lateinit var newAuthMode: AuthMode + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val isClone = intent?.extras?.getInt(REQUEST_ARG_OP) ?: -1 == REQUEST_CLONE @@ -41,78 +43,64 @@ class GitServerConfigActivity : BaseGitActivity() { setContentView(binding.root) supportActionBar?.setDisplayHomeAsUpEnabled(true) - binding.cloneProtocolGroup.check(when (GitSettings.protocol) { - Protocol.Ssh -> R.id.clone_protocol_ssh - Protocol.Https -> R.id.clone_protocol_https - }) - binding.cloneProtocolGroup.addOnButtonCheckedListener { _, checkedId, checked -> - if (checked) { - when (checkedId) { - R.id.clone_protocol_https -> GitSettings.protocol = Protocol.Https - R.id.clone_protocol_ssh -> GitSettings.protocol = Protocol.Ssh - } - updateConnectionModeToggleGroup() - } - } + newAuthMode = GitSettings.authMode - binding.connectionModeGroup.apply { - when (GitSettings.connectionMode) { - ConnectionMode.SshKey -> check(R.id.connection_mode_ssh_key) - ConnectionMode.Password -> check(R.id.connection_mode_password) - ConnectionMode.OpenKeychain -> check(R.id.connection_mode_open_keychain) - ConnectionMode.None -> uncheck(checkedButtonId) + binding.authModeGroup.apply { + when (newAuthMode) { + AuthMode.SshKey -> check(R.id.auth_mode_ssh_key) + AuthMode.Password -> check(R.id.auth_mode_password) + AuthMode.OpenKeychain -> check(R.id.auth_mode_open_keychain) + AuthMode.None -> uncheck(checkedButtonId) } addOnButtonCheckedListener { _, _, _ -> when (checkedButtonId) { - R.id.connection_mode_ssh_key -> GitSettings.connectionMode = ConnectionMode.SshKey - R.id.connection_mode_open_keychain -> GitSettings.connectionMode = ConnectionMode.OpenKeychain - R.id.connection_mode_password -> GitSettings.connectionMode = ConnectionMode.Password - View.NO_ID -> GitSettings.connectionMode = ConnectionMode.None + R.id.auth_mode_ssh_key -> newAuthMode = AuthMode.SshKey + R.id.auth_mode_open_keychain -> newAuthMode = AuthMode.OpenKeychain + R.id.auth_mode_password -> newAuthMode = AuthMode.Password + View.NO_ID -> newAuthMode = AuthMode.None } } } - updateConnectionModeToggleGroup() binding.serverUrl.setText(GitSettings.url) binding.serverBranch.setText(GitSettings.branch) binding.saveButton.setOnClickListener { - if (isClone && PasswordRepository.getRepository(null) == null) - PasswordRepository.initialize() - GitSettings.branch = binding.serverBranch.text.toString().trim() - if (GitSettings.updateUrlIfValid(binding.serverUrl.text.toString().trim())) { - if (!isClone) { - Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show() - Handler().postDelayed(500) { finish() } - } else { - cloneRepository() + when (val updateResult = GitSettings.updateConnectionSettingsIfValid( + newAuthMode = newAuthMode, + newUrl = binding.serverUrl.text.toString().trim(), + newBranch = binding.serverBranch.text.toString().trim())) { + GitSettings.UpdateConnectionSettingsResult.FailedToParseUrl -> { + Snackbar.make(binding.root, getString(R.string.git_server_config_save_error), Snackbar.LENGTH_LONG).show() + } + is GitSettings.UpdateConnectionSettingsResult.MissingUsername -> { + when (updateResult.newProtocol) { + Protocol.Https -> Snackbar.make(binding.root, getString(R.string.git_server_config_save_missing_username_https), Snackbar.LENGTH_LONG).show() + Protocol.Ssh -> Snackbar.make(binding.root, getString(R.string.git_server_config_save_missing_username_ssh), Snackbar.LENGTH_LONG).show() + } + } + GitSettings.UpdateConnectionSettingsResult.Valid -> { + if (isClone && PasswordRepository.getRepository(null) == null) + PasswordRepository.initialize() + if (!isClone) { + Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show() + Handler().postDelayed(500) { finish() } + } else { + cloneRepository() + } + } + is GitSettings.UpdateConnectionSettingsResult.AuthModeMismatch -> { + val message = getString( + R.string.git_server_config_save_auth_mode_mismatch, + updateResult.newProtocol, + updateResult.validModes.joinToString(", "), + ) + Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show() } - } else { - Snackbar.make(binding.root, getString(R.string.git_server_config_save_error), Snackbar.LENGTH_LONG).show() } } } - private fun updateConnectionModeToggleGroup() { - if (GitSettings.protocol == Protocol.Ssh) { - // Reset connection mode to SSH key if the current value (none) is not valid for SSH - if (binding.connectionModeGroup.checkedButtonIds.isEmpty()) - binding.connectionModeGroup.check(R.id.connection_mode_ssh_key) - binding.connectionModeSshKey.isEnabled = true - binding.connectionModeOpenKeychain.isEnabled = true - binding.connectionModeGroup.isSelectionRequired = true - } else { - binding.connectionModeGroup.isSelectionRequired = false - // Reset connection mode to password if the current value is not valid for HTTPS - // Important note: This has to happen before disabling the other toggle buttons or they - // won't uncheck. - if (GitSettings.connectionMode !in listOf(ConnectionMode.None, ConnectionMode.Password)) - binding.connectionModeGroup.check(R.id.connection_mode_password) - binding.connectionModeSshKey.isEnabled = false - binding.connectionModeOpenKeychain.isEnabled = false - } - } - /** * Clones the repository, the directory exists, deletes it */ diff --git a/app/src/main/java/com/zeapo/pwdstore/git/config/GitSettings.kt b/app/src/main/java/com/zeapo/pwdstore/git/config/GitSettings.kt index b81a8f8f..b0d931e0 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/config/GitSettings.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/config/GitSettings.kt @@ -29,7 +29,7 @@ enum class Protocol(val pref: String) { } } -enum class ConnectionMode(val pref: String) { +enum class AuthMode(val pref: String) { SshKey("ssh-key"), Password("username/password"), OpenKeychain("OpenKeychain"), @@ -38,10 +38,10 @@ enum class ConnectionMode(val pref: String) { companion object { - private val map = values().associateBy(ConnectionMode::pref) - fun fromString(type: String?): ConnectionMode { + private val map = values().associateBy(AuthMode::pref) + fun fromString(type: String?): AuthMode { return map[type ?: return SshKey] - ?: throw IllegalArgumentException("$type is not a valid ConnectionMode") + ?: throw IllegalArgumentException("$type is not a valid AuthMode") } } } @@ -53,16 +53,9 @@ object GitSettings { private val settings by lazy { Application.instance.sharedPrefs } private val encryptedSettings by lazy { Application.instance.getEncryptedPrefs("git_operation") } - var protocol - get() = Protocol.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_PROTOCOL)) - set(value) { - settings.edit { - putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, value.pref) - } - } - var connectionMode - get() = ConnectionMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH)) - set(value) { + var authMode + get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH)) + private set(value) { settings.edit { putString(PreferenceKeys.GIT_REMOTE_AUTH, value.pref) } @@ -71,6 +64,8 @@ object GitSettings { get() = settings.getString(PreferenceKeys.GIT_REMOTE_URL) private set(value) { require(value != null) + if (value == url) + return settings.edit { putString(PreferenceKeys.GIT_REMOTE_URL, value) } @@ -96,20 +91,47 @@ object GitSettings { } var branch get() = settings.getString(PreferenceKeys.GIT_BRANCH_NAME) ?: DEFAULT_BRANCH - set(value) { + private set(value) { settings.edit { putString(PreferenceKeys.GIT_BRANCH_NAME, value) } } - fun updateUrlIfValid(newUrl: String): Boolean { - try { + sealed class UpdateConnectionSettingsResult { + class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult() + class AuthModeMismatch(val newProtocol: Protocol, val validModes: List<AuthMode>) : UpdateConnectionSettingsResult() + object Valid : UpdateConnectionSettingsResult() + object FailedToParseUrl : UpdateConnectionSettingsResult() + } + + fun updateConnectionSettingsIfValid(newAuthMode: AuthMode, newUrl: String, newBranch: String): UpdateConnectionSettingsResult { + val parsedUrl = try { URIish(newUrl) } catch (_: Exception) { - return false + return UpdateConnectionSettingsResult.FailedToParseUrl + } + val newProtocol = when (parsedUrl.scheme) { + in listOf("http", "https") -> Protocol.Https + in listOf("ssh", null) -> Protocol.Ssh + else -> return UpdateConnectionSettingsResult.FailedToParseUrl + } + if (newAuthMode != AuthMode.None && parsedUrl.user.isNullOrBlank()) + return UpdateConnectionSettingsResult.MissingUsername(newProtocol) + + val validHttpsAuth = listOf(AuthMode.None, AuthMode.Password) + val validSshAuth = listOf(AuthMode.OpenKeychain, AuthMode.Password, AuthMode.SshKey) + when { + newProtocol == Protocol.Https && newAuthMode !in validHttpsAuth -> { + return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validHttpsAuth) + } + newProtocol == Protocol.Ssh && newAuthMode !in validSshAuth -> { + return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validSshAuth) + } } - if (newUrl != url) - url = newUrl - return true + + url = newUrl + authMode = newAuthMode + branch = newBranch + return UpdateConnectionSettingsResult.Valid } } 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 deleted file mode 100644 index 03760741..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java +++ /dev/null @@ -1,383 +0,0 @@ -/* - * 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 android.app.PendingIntent; -import android.content.Intent; -import android.content.IntentSender; -import android.content.SharedPreferences; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatActivity; -import androidx.preference.PreferenceManager; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.jcraft.jsch.Identity; -import com.jcraft.jsch.JSch; -import com.jcraft.jsch.JSchException; -import com.jcraft.jsch.Session; -import com.zeapo.pwdstore.R; -import com.zeapo.pwdstore.git.BaseGitActivity; -import com.zeapo.pwdstore.utils.PreferenceKeys; - -import org.eclipse.jgit.transport.JschConfigSessionFactory; -import org.eclipse.jgit.transport.OpenSshConfig; -import org.eclipse.jgit.util.Base64; -import org.eclipse.jgit.util.FS; -import org.openintents.ssh.authentication.ISshAuthenticationService; -import org.openintents.ssh.authentication.SshAuthenticationApi; -import org.openintents.ssh.authentication.SshAuthenticationApiError; -import org.openintents.ssh.authentication.SshAuthenticationConnection; -import org.openintents.ssh.authentication.request.KeySelectionRequest; -import org.openintents.ssh.authentication.request.Request; -import org.openintents.ssh.authentication.request.SigningRequest; -import org.openintents.ssh.authentication.request.SshPublicKeyRequest; -import org.openintents.ssh.authentication.util.SshAuthenticationApiUtils; - -import java.util.List; -import java.util.concurrent.CountDownLatch; - -public class SshApiSessionFactory extends JschConfigSessionFactory { - /** - * Intent request code indicating a completed signature that should be posted to an outstanding - * ApiIdentity - */ - public static final int POST_SIGNATURE = 301; - - private final Identity identity; - - public SshApiSessionFactory(Identity identity) { - this.identity = identity; - } - - @NonNull - @Override - protected JSch getJSch(@NonNull final OpenSshConfig.Host hc, @NonNull FS fs) - throws JSchException { - JSch jsch = super.getJSch(hc, fs); - jsch.removeAllIdentity(); - jsch.addIdentity(identity, null); - return jsch; - } - - @Override - protected void configure(@NonNull OpenSshConfig.Host hc, Session session) { - session.setConfig("StrictHostKeyChecking", "no"); - session.setConfig("PreferredAuthentications", "publickey"); - } - - /** - * Helper to build up an ApiIdentity via the invocation of several pending intents that - * communicate with OpenKeychain. The user of this class must handle onActivityResult and keep - * feeding the resulting intents into the IdentityBuilder until it can successfully complete the - * build. - */ - public static class IdentityBuilder { - private final SshAuthenticationConnection connection; - private final BaseGitActivity callingActivity; - private final SharedPreferences settings; - private SshAuthenticationApi api; - private String keyId, description, alg; - private byte[] publicKey; - - /** - * Construct a new IdentityBuilder - * - * @param callingActivity Activity that will be used to launch pending intents and that will - * receive and handle the results. - */ - public IdentityBuilder(BaseGitActivity callingActivity) { - this.callingActivity = callingActivity; - - List<String> providers = - SshAuthenticationApiUtils.getAuthenticationProviderPackageNames( - callingActivity); - if (providers.isEmpty()) - throw new RuntimeException(callingActivity.getString(R.string.no_ssh_api_provider)); - - // TODO: Handle multiple available providers? Are there actually any in practice beyond - // OpenKeychain? - connection = new SshAuthenticationConnection(callingActivity, providers.get(0)); - - settings = - PreferenceManager.getDefaultSharedPreferences( - callingActivity.getApplicationContext()); - keyId = settings.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null); - } - - /** - * Free any resources associated with this IdentityBuilder - */ - public void close() { - if (connection != null && connection.isConnected()) connection.disconnect(); - } - - /** - * Helper to invoke an OpenKeyshain SSH API method and correctly interpret the result. - * - * @param request The request intent to launch - * @param requestCode The request code to use if a pending intent needs to be sent - * @return The resulting intent if the request completed immediately, or null if we had to - * launch a pending intent to interact with the user - */ - private Intent executeApi(Request request, int requestCode) { - Intent result = api.executeApi(request.toIntent()); - - switch (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, -1)) { - case SshAuthenticationApi.RESULT_CODE_ERROR: - SshAuthenticationApiError error = - result.getParcelableExtra(SshAuthenticationApi.EXTRA_ERROR); - // On an OpenKeychain SSH API error, clear out the stored keyid - 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 - // different one - case (SshAuthenticationApiError.NO_SUCH_KEY): - case (SshAuthenticationApiError.NO_AUTH_KEY): - keyId = null; - publicKey = null; - description = null; - alg = null; - return executeApi(new KeySelectionRequest(), requestCode); - - // Other errors are fatal - default: - throw new RuntimeException(error.getMessage()); - } - case SshAuthenticationApi.RESULT_CODE_SUCCESS: - break; - case SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED: - PendingIntent pendingIntent = - result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT); - try { - callingActivity.startIntentSenderForResult( - pendingIntent.getIntentSender(), requestCode, null, 0, 0, 0); - return null; - } catch (IntentSender.SendIntentException e) { - e.printStackTrace(); - throw new RuntimeException( - callingActivity.getString(R.string.ssh_api_pending_intent_failed)); - } - default: - throw new RuntimeException( - callingActivity.getString(R.string.ssh_api_unknown_error)); - } - - return result; - } - - /** - * Parse a given intent to see if it is the result of an OpenKeychain pending intent. If so, - * extract any updated state from it. - * - * @param intent The intent to inspect - */ - public void consume(Intent intent) { - if (intent == null) return; - - if (intent.hasExtra(SshAuthenticationApi.EXTRA_KEY_ID)) { - keyId = intent.getStringExtra(SshAuthenticationApi.EXTRA_KEY_ID); - description = intent.getStringExtra(SshAuthenticationApi.EXTRA_KEY_DESCRIPTION); - settings.edit().putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, keyId).apply(); - } - - if (intent.hasExtra(SshAuthenticationApi.EXTRA_SSH_PUBLIC_KEY)) { - String keyStr = intent.getStringExtra(SshAuthenticationApi.EXTRA_SSH_PUBLIC_KEY); - String[] keyParts = keyStr.split(" "); - alg = keyParts[0]; - publicKey = Base64.decode(keyParts[1]); - } - } - - /** - * Try to build an ApiIdentity that will perform SSH authentication via OpenKeychain. - * - * @param requestCode The request code to use if a pending intent needs to be sent - * @return The built identity, or null of user interaction is still required (in which case - * a pending intent will have already been launched) - */ - public ApiIdentity tryBuild(int requestCode) { - // First gate, need to initiate a connection to the service and wait for it to connect. - if (api == null) { - connection.connect( - new SshAuthenticationConnection.OnBound() { - @Override - public void onBound(ISshAuthenticationService sshAgent) { - api = new SshAuthenticationApi(callingActivity, sshAgent); - // We can immediately try the next phase without needing to post - // back - // though onActivityResult - callingActivity.onActivityResult( - requestCode, AppCompatActivity.RESULT_OK, null); - } - - @Override - public void onError() { - new MaterialAlertDialogBuilder(callingActivity) - .setMessage( - callingActivity.getString( - R.string.openkeychain_ssh_api_connect_fail)) - .show(); - } - }); - - return null; - } - - // Second gate, need the user to select which key they want to use - if (keyId == null) { - consume(executeApi(new KeySelectionRequest(), requestCode)); - // If we did not immediately get the result, bail for now and wait to be re-entered - if (keyId == null) return null; - } - - // Third gate, need to get the public key for the selected key. This one often does not - // need use interaction. - if (publicKey == null) { - consume(executeApi(new SshPublicKeyRequest(keyId), requestCode)); - // If we did not immediately get the result, bail for now and wait to be re-entered - if (publicKey == null) return null; - } - - // Have everything we need for now, build the identify - return new ApiIdentity(keyId, description, publicKey, alg, callingActivity, api); - } - } - - /** - * A Jsch identity that delegates key operations via the OpenKeychain SSH API - */ - public static class ApiIdentity implements Identity { - private final String keyId; - private final String description; - private final String alg; - private final byte[] publicKey; - private final AppCompatActivity callingActivity; - private final SshAuthenticationApi api; - private CountDownLatch latch; - private byte[] signature; - - ApiIdentity( - String keyId, - String description, - byte[] publicKey, - String alg, - AppCompatActivity callingActivity, - SshAuthenticationApi api) { - this.keyId = keyId; - this.description = description; - this.publicKey = publicKey; - this.alg = alg; - this.callingActivity = callingActivity; - this.api = api; - } - - @Override - public boolean setPassphrase(byte[] passphrase) { - // We are not encrypted with a passphrase - return true; - } - - @Override - public byte[] getPublicKeyBlob() { - return publicKey; - } - - /** - * Helper to handle the result of an OpenKeyshain SSH API signing request - * - * @param result The result intent to handle - * @return The signed challenge, or null if it was not immediately available, in which case - * the latch has been initialized and the pending intent started - */ - private byte[] handleSignResult(Intent result) { - switch (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, -1)) { - case SshAuthenticationApi.RESULT_CODE_ERROR: - SshAuthenticationApiError error = - result.getParcelableExtra(SshAuthenticationApi.EXTRA_ERROR); - throw new RuntimeException(error.getMessage()); - case SshAuthenticationApi.RESULT_CODE_SUCCESS: - return result.getByteArrayExtra(SshAuthenticationApi.EXTRA_SIGNATURE); - case SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED: - PendingIntent pendingIntent = - result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT); - try { - latch = new CountDownLatch(1); - callingActivity.startIntentSenderForResult( - pendingIntent.getIntentSender(), POST_SIGNATURE, null, 0, 0, 0); - return null; - - } catch (Exception e) { - e.printStackTrace(); - throw new RuntimeException( - callingActivity.getString(R.string.ssh_api_pending_intent_failed)); - } - default: - if (result.hasExtra(SshAuthenticationApi.EXTRA_CHALLENGE)) - return handleSignResult(api.executeApi(result)); - throw new RuntimeException( - callingActivity.getString(R.string.ssh_api_unknown_error)); - } - } - - @Override - public byte[] getSignature(byte[] data) { - Intent request = new SigningRequest(data, keyId, SshAuthenticationApi.SHA1).toIntent(); - signature = handleSignResult(api.executeApi(request)); - - // If we did not immediately get a signature (probable), we will block on a latch until - // the main activity gets the intent result and posts to us. - if (signature == null) { - try { - latch.await(); - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - - return signature; - } - - /** - * Post a signature response back to an in-progress operation using this ApiIdentity. - * - * @param data The signature data (hopefully) - */ - public void postSignature(Intent data) { - try { - if (data != null) { - signature = handleSignResult(data); - } - } finally { - if (latch != null) latch.countDown(); - } - } - - @Override - public boolean decrypt() { - return true; - } - - @Override - public String getAlgName() { - return alg; - } - - @Override - public String getName() { - return description; - } - - @Override - public boolean isEncrypted() { - return false; - } - - @Override - public void clear() { - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt index 423ceb80..b7b4f881 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt @@ -10,8 +10,8 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.git.config.ConnectionMode -import com.zeapo.pwdstore.git.config.InteractivePasswordFinder +import com.zeapo.pwdstore.git.config.AuthMode +import com.zeapo.pwdstore.git.sshj.InteractivePasswordFinder import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.getEncryptedPrefs import com.zeapo.pwdstore.utils.requestInputFocusOnView @@ -20,7 +20,7 @@ import kotlin.coroutines.resume class CredentialFinder( val callingActivity: FragmentActivity, - val connectionMode: ConnectionMode + val authMode: AuthMode ) : InteractivePasswordFinder() { override fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) { @@ -30,15 +30,15 @@ class CredentialFinder( @StringRes val hintRes: Int @StringRes val rememberRes: Int @StringRes val errorRes: Int - when (connectionMode) { - ConnectionMode.SshKey -> { + when (authMode) { + AuthMode.SshKey -> { credentialPref = PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE messageRes = R.string.passphrase_dialog_text hintRes = R.string.ssh_keygen_passphrase rememberRes = R.string.git_operation_remember_passphrase errorRes = R.string.git_operation_wrong_passphrase } - ConnectionMode.Password -> { + AuthMode.Password -> { // Could be either an SSH or an HTTPS password credentialPref = PreferenceKeys.HTTPS_PASSWORD messageRes = R.string.password_dialog_text diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt index dc768a6e..806bbb7c 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt @@ -8,21 +8,20 @@ import android.content.Intent import androidx.annotation.CallSuper import androidx.core.content.edit import androidx.fragment.app.FragmentActivity -import androidx.preference.PreferenceManager import com.github.ajalt.timberkt.Timber.d import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.zeapo.pwdstore.R import com.zeapo.pwdstore.UserPreference import com.zeapo.pwdstore.git.ErrorMessages -import com.zeapo.pwdstore.git.config.ConnectionMode +import com.zeapo.pwdstore.git.config.AuthMode import com.zeapo.pwdstore.git.config.GitSettings -import com.zeapo.pwdstore.git.config.InteractivePasswordFinder -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.git.sshj.InteractivePasswordFinder +import com.zeapo.pwdstore.git.sshj.SshAuthData +import com.zeapo.pwdstore.git.sshj.SshjSessionFactory import com.zeapo.pwdstore.utils.PasswordRepository import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.getEncryptedPrefs +import com.zeapo.pwdstore.utils.sharedPrefs import java.io.File import net.schmizz.sshj.userauth.password.PasswordFinder import org.eclipse.jgit.api.Git @@ -96,8 +95,9 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment return this } - private fun withOpenKeychainAuthentication(identity: SshApiSessionFactory.ApiIdentity?): GitOperation { - SshSessionFactory.setInstance(SshApiSessionFactory(identity)) + private fun withOpenKeychainAuthentication(activity: FragmentActivity): GitOperation { + val sessionFactory = SshjSessionFactory(SshAuthData.OpenKeychain(activity), hostKeyFile) + SshSessionFactory.setInstance(sessionFactory) this.provider = null return this } @@ -127,11 +127,10 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment abstract suspend fun execute() suspend fun executeAfterAuthentication( - connectionMode: ConnectionMode, - identity: SshApiSessionFactory.ApiIdentity? + authMode: AuthMode, ) { - when (connectionMode) { - ConnectionMode.SshKey -> if (!sshKeyFile.exists()) { + when (authMode) { + AuthMode.SshKey -> if (!sshKeyFile.exists()) { MaterialAlertDialogBuilder(callingActivity) .setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text)) .setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title)) @@ -147,12 +146,12 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment }.show() } else { withPublicKeyAuthentication( - CredentialFinder(callingActivity, connectionMode)).execute() + CredentialFinder(callingActivity, authMode)).execute() } - ConnectionMode.OpenKeychain -> withOpenKeychainAuthentication(identity).execute() - ConnectionMode.Password -> withPasswordAuthentication( - CredentialFinder(callingActivity, connectionMode)).execute() - ConnectionMode.None -> execute() + AuthMode.OpenKeychain -> withOpenKeychainAuthentication(callingActivity).execute() + AuthMode.Password -> withPasswordAuthentication( + CredentialFinder(callingActivity, authMode)).execute() + AuthMode.None -> execute() } } @@ -162,17 +161,10 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment @CallSuper open fun onError(err: Exception) { // Clear various auth related fields on failure - when (SshSessionFactory.getInstance()) { - is SshApiSessionFactory -> { - PreferenceManager.getDefaultSharedPreferences(callingActivity.applicationContext) - .edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } - } - is SshjSessionFactory -> { - callingActivity.getEncryptedPrefs("git_operation").edit { - remove(PreferenceKeys.HTTPS_PASSWORD) - } - } + callingActivity.getEncryptedPrefs("git_operation").edit { + remove(PreferenceKeys.HTTPS_PASSWORD) } + callingActivity.sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } d(err) MaterialAlertDialogBuilder(callingActivity) .setTitle(callingActivity.resources.getString(R.string.jgit_error_dialog_title)) diff --git a/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt new file mode 100644 index 00000000..cecf7505 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt @@ -0,0 +1,212 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.git.sshj + +import android.app.PendingIntent +import android.content.Intent +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.edit +import androidx.fragment.app.FragmentActivity +import com.github.ajalt.timberkt.d +import com.zeapo.pwdstore.utils.OPENPGP_PROVIDER +import com.zeapo.pwdstore.utils.PreferenceKeys +import com.zeapo.pwdstore.utils.sharedPrefs +import java.io.Closeable +import java.security.PublicKey +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.schmizz.sshj.common.Base64 +import net.schmizz.sshj.common.Buffer +import net.schmizz.sshj.common.DisconnectReason +import net.schmizz.sshj.common.KeyType +import net.schmizz.sshj.userauth.UserAuthException +import net.schmizz.sshj.userauth.keyprovider.KeyProvider +import org.openintents.ssh.authentication.ISshAuthenticationService +import org.openintents.ssh.authentication.SshAuthenticationApi +import org.openintents.ssh.authentication.SshAuthenticationApiError +import org.openintents.ssh.authentication.SshAuthenticationConnection +import org.openintents.ssh.authentication.request.KeySelectionRequest +import org.openintents.ssh.authentication.request.Request +import org.openintents.ssh.authentication.request.SigningRequest +import org.openintents.ssh.authentication.request.SshPublicKeyRequest +import org.openintents.ssh.authentication.response.KeySelectionResponse +import org.openintents.ssh.authentication.response.Response +import org.openintents.ssh.authentication.response.SigningResponse +import org.openintents.ssh.authentication.response.SshPublicKeyResponse + +class OpenKeychainKeyProvider private constructor(private val activity: FragmentActivity) : KeyProvider, Closeable { + + companion object { + + suspend fun prepareAndUse(activity: FragmentActivity, block: (provider: OpenKeychainKeyProvider) -> Unit) { + withContext(Dispatchers.Main){ + OpenKeychainKeyProvider(activity) + }.prepareAndUse(block) + } + } + + private sealed class ApiResponse { + data class Success(val response: Response) : ApiResponse() + data class GeneralError(val exception: Exception) : ApiResponse() + data class NoSuchKey(val exception: Exception) : ApiResponse() + } + + private val context = activity.applicationContext + private val sshServiceConnection = SshAuthenticationConnection(context, OPENPGP_PROVIDER) + private val preferences = context.sharedPrefs + private val continueAfterUserInteraction = + activity.registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> + currentCont?.let { cont -> + currentCont = null + val data = result.data + if (data != null) + cont.resume(data) + else + cont.resumeWithException(UserAuthException(DisconnectReason.AUTH_CANCELLED_BY_USER)) + } + } + + private lateinit var sshServiceApi: SshAuthenticationApi + + private var currentCont: Continuation<Intent>? = null + private var keyId + get() = preferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) + set(value) { + preferences.edit { + putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, value) + } + } + private var publicKey: PublicKey? = null + private var privateKey: OpenKeychainPrivateKey? = null + + private suspend fun prepareAndUse(block: (provider: OpenKeychainKeyProvider) -> Unit) { + prepare() + use(block) + } + + private suspend fun prepare() { + sshServiceApi = suspendCoroutine { cont -> + sshServiceConnection.connect(object : SshAuthenticationConnection.OnBound { + override fun onBound(sshAgent: ISshAuthenticationService) { + d { "Bound to SshAuthenticationApi: $OPENPGP_PROVIDER" } + cont.resume(SshAuthenticationApi(context, sshAgent)) + } + + override fun onError() { + throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable") + } + }) + } + + if (keyId == null) { + selectKey() + } + check(keyId != null) + fetchPublicKey() + makePrivateKey() + } + + private suspend fun fetchPublicKey(isRetry: Boolean = false) { + when (val sshPublicKeyResponse = executeApiRequest(SshPublicKeyRequest(keyId))) { + is ApiResponse.Success -> { + val response = sshPublicKeyResponse.response as SshPublicKeyResponse + val sshPublicKey = response.sshPublicKey!! + val sshKeyParts = sshPublicKey.split("""\s+""".toRegex()) + check(sshKeyParts.size >= 2) { "OpenKeychain API returned invalid SSH key" } + @Suppress("BlockingMethodInNonBlockingContext") + publicKey = Buffer.PlainBuffer(Base64.decode(sshKeyParts[1])).readPublicKey() + } + is ApiResponse.NoSuchKey -> if (isRetry) { + throw sshPublicKeyResponse.exception + } else { + // Allow the user to reselect an authentication key and retry + selectKey() + fetchPublicKey(true) + } + is ApiResponse.GeneralError -> throw sshPublicKeyResponse.exception + } + } + + private suspend fun selectKey() { + when (val keySelectionResponse = executeApiRequest(KeySelectionRequest())) { + is ApiResponse.Success -> keyId = (keySelectionResponse.response as KeySelectionResponse).keyId + is ApiResponse.GeneralError -> throw keySelectionResponse.exception + is ApiResponse.NoSuchKey -> throw keySelectionResponse.exception + } + } + + private suspend fun executeApiRequest(request: Request, resultOfUserInteraction: Intent? = null): ApiResponse { + d { "executeRequest($request) called" } + val result = withContext(Dispatchers.Main) { + // If the request required user interaction, the data returned from the PendingIntent + // is used as the real request. + sshServiceApi.executeApi(resultOfUserInteraction ?: request.toIntent())!! + } + return parseResult(request, result).also { + d { "executeRequest($request): $it" } + } + } + + private suspend fun parseResult(request: Request, result: Intent): ApiResponse { + return when (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, SshAuthenticationApi.RESULT_CODE_ERROR)) { + SshAuthenticationApi.RESULT_CODE_SUCCESS -> { + ApiResponse.Success(when (request) { + is KeySelectionRequest -> KeySelectionResponse(result) + is SshPublicKeyRequest -> SshPublicKeyResponse(result) + is SigningRequest -> SigningResponse(result) + else -> throw IllegalArgumentException("Unsupported OpenKeychain request type") + }) + } + SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val pendingIntent: PendingIntent = result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!! + val resultOfUserInteraction: Intent = withContext(Dispatchers.Main) { + suspendCoroutine { cont -> + currentCont = cont + continueAfterUserInteraction.launch(IntentSenderRequest.Builder(pendingIntent).build()) + } + } + executeApiRequest(request, resultOfUserInteraction) + } + else -> { + val error = result.getParcelableExtra<SshAuthenticationApiError>(SshAuthenticationApi.EXTRA_ERROR) + val exception = UserAuthException(DisconnectReason.UNKNOWN, "Request ${request::class.simpleName} failed: ${error?.message}") + when (error?.error) { + SshAuthenticationApiError.NO_AUTH_KEY, SshAuthenticationApiError.NO_SUCH_KEY -> ApiResponse.NoSuchKey(exception) + else -> ApiResponse.GeneralError(exception) + } + } + } + } + + private fun makePrivateKey() { + check(keyId != null && publicKey != null) + privateKey = object : OpenKeychainPrivateKey { + override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) = + when (val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))) { + is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature + is ApiResponse.GeneralError -> throw signingResponse.exception + is ApiResponse.NoSuchKey -> throw signingResponse.exception + } + + override fun getAlgorithm() = publicKey!!.algorithm + } + } + + override fun close() { + continueAfterUserInteraction.unregister() + sshServiceConnection.disconnect() + } + + override fun getPrivate() = privateKey + + override fun getPublic() = publicKey + + override fun getType() = KeyType.fromKey(publicKey) +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt new file mode 100644 index 00000000..97b587fd --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt @@ -0,0 +1,93 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.git.sshj + +import com.hierynomus.sshj.key.KeyAlgorithm +import java.io.ByteArrayOutputStream +import java.security.PrivateKey +import kotlinx.coroutines.runBlocking +import net.schmizz.sshj.common.Buffer +import net.schmizz.sshj.common.Factory +import net.schmizz.sshj.signature.Signature +import org.openintents.ssh.authentication.SshAuthenticationApi + +interface OpenKeychainPrivateKey : PrivateKey { + + suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray + + override fun getFormat() = null + override fun getEncoded() = null +} + +class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named<KeyAlgorithm>) : Factory.Named<KeyAlgorithm> by factory { + + override fun create() = OpenKeychainWrappedKeyAlgorithm(factory.create()) +} + +class OpenKeychainWrappedKeyAlgorithm(private val keyAlgorithm: KeyAlgorithm) : KeyAlgorithm by keyAlgorithm { + + private val hashAlgorithm = when (keyAlgorithm.keyAlgorithm) { + "rsa-sha2-512" -> SshAuthenticationApi.SHA512 + "rsa-sha2-256" -> SshAuthenticationApi.SHA256 + "ssh-rsa", "ssh-rsa-cert-v01@openssh.com" -> SshAuthenticationApi.SHA1 + // Other algorithms don't use this value, but it has to be valid. + else -> SshAuthenticationApi.SHA512 + } + + override fun newSignature() = OpenKeychainWrappedSignature(keyAlgorithm.newSignature(), hashAlgorithm) +} + +class OpenKeychainWrappedSignature(private val wrappedSignature: Signature, private val hashAlgorithm: Int) : Signature by wrappedSignature { + + private val data = ByteArrayOutputStream() + + private var bridgedPrivateKey: OpenKeychainPrivateKey? = null + + override fun initSign(prvkey: PrivateKey?) { + if (prvkey is OpenKeychainPrivateKey) { + bridgedPrivateKey = prvkey + } else { + wrappedSignature.initSign(prvkey) + } + } + + override fun update(H: ByteArray?) { + if (bridgedPrivateKey != null) { + data.write(H!!) + } else { + wrappedSignature.update(H) + } + } + + override fun update(H: ByteArray?, off: Int, len: Int) { + if (bridgedPrivateKey != null) { + data.write(H!!, off, len) + } else { + wrappedSignature.update(H, off, len) + } + } + + override fun sign(): ByteArray? = if (bridgedPrivateKey != null) { + runBlocking { + bridgedPrivateKey!!.sign(data.toByteArray(), hashAlgorithm) + } + } else { + wrappedSignature.sign() + } + + override fun encode(signature: ByteArray?): ByteArray? = if (bridgedPrivateKey != null) { + require(signature != null) { "OpenKeychain signature must not be null" } + val encodedSignature = Buffer.PlainBuffer(signature) + // We need to drop the algorithm name and extract the raw signature since SSHJ adds the name + // later. + encodedSignature.readString() + encodedSignature.readBytes().also { + bridgedPrivateKey = null + data.reset() + } + } else { + wrappedSignature.encode(signature) + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/config/SshjConfig.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt index 6c409329..bf454cd5 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/config/SshjConfig.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt @@ -2,7 +2,7 @@ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. * SPDX-License-Identifier: GPL-3.0-only */ -package com.zeapo.pwdstore.git.config +package com.zeapo.pwdstore.git.sshj import com.github.ajalt.timberkt.Timber import com.github.ajalt.timberkt.d @@ -232,7 +232,9 @@ class SshjConfig : ConfigImpl() { KeyAlgorithms.ECDSASHANistp384(), KeyAlgorithms.ECDSASHANistp256(), KeyAlgorithms.SSHRSA(), - ) + ).map { + OpenKeychainWrappedKeyAlgorithmFactory(it) + } } private fun initRandomFactory() { diff --git a/app/src/main/java/com/zeapo/pwdstore/git/config/SshjSessionFactory.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt index 85b5f753..05428e41 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/config/SshjSessionFactory.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt @@ -2,9 +2,10 @@ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. * SPDX-License-Identifier: GPL-3.0-only */ -package com.zeapo.pwdstore.git.config +package com.zeapo.pwdstore.git.sshj import android.util.Base64 +import androidx.fragment.app.FragmentActivity import com.github.ajalt.timberkt.d import com.github.ajalt.timberkt.w import java.io.File @@ -37,6 +38,7 @@ import org.eclipse.jgit.util.FS sealed class SshAuthData { class Password(val passwordFinder: InteractivePasswordFinder) : SshAuthData() class PublicKeyFile(val keyFile: File, val passphraseFinder: InteractivePasswordFinder) : SshAuthData() + class OpenKeychain(val activity: FragmentActivity) : SshAuthData() } abstract class InteractivePasswordFinder : PasswordFinder { @@ -128,6 +130,13 @@ private class SshjSession(uri: URIish, private val username: String, private val is SshAuthData.PublicKeyFile -> { ssh.authPublickey(username, ssh.loadKeys(authData.keyFile.absolutePath, authData.passphraseFinder)) } + is SshAuthData.OpenKeychain -> { + runBlocking { + OpenKeychainKeyProvider.prepareAndUse(authData.activity) { provider -> + ssh.authPublickey(username, provider) + } + } + } } return this } 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 dc9d0ebd..2915efb4 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 @@ -50,7 +50,7 @@ open class PasswordItemRecyclerAdapter : fun bind(item: PasswordItem) { val settings = itemView.context.sharedPrefs - val showHidden = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false) + val showHidden = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false) val parentPath = item.fullPathToParent.replace("(^/)|(/$)".toRegex(), "") val source = if (parentPath.isNotEmpty()) { "$parentPath\n$item" @@ -62,8 +62,8 @@ open class PasswordItemRecyclerAdapter : name.text = spannable if (item.type == PasswordItem.TYPE_CATEGORY) { folderIndicator.visibility = View.VISIBLE - val children = item.file.listFiles { pathname -> - !(!showHidden && (pathname.isDirectory && pathname.isHidden)) + val children = with(item.file) { + if (showHidden) listFiles() else listFiles { pathname -> pathname.isDirectory && !pathname.isHidden } } ?: emptyArray<File>() val count = children.size childCount.visibility = if (count > 0) View.VISIBLE else View.GONE diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/BiometricAuthenticator.kt b/app/src/main/java/com/zeapo/pwdstore/utils/BiometricAuthenticator.kt index d29e7ac4..0792c1fc 100644 --- a/app/src/main/java/com/zeapo/pwdstore/utils/BiometricAuthenticator.kt +++ b/app/src/main/java/com/zeapo/pwdstore/utils/BiometricAuthenticator.kt @@ -9,6 +9,7 @@ import android.os.Handler import androidx.annotation.StringRes import androidx.biometric.BiometricConstants import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators import androidx.biometric.BiometricPrompt import androidx.core.content.getSystemService import androidx.fragment.app.FragmentActivity @@ -60,14 +61,15 @@ object BiometricAuthenticator { callback(Result.Success(result.cryptoObject)) } } - val biometricPrompt = BiometricPrompt(activity, { handler.post(it) }, authCallback) - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle(activity.getString(dialogTitleRes)) - .setDeviceCredentialAllowed(true) - .build() - if (BiometricManager.from(activity).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS || - activity.getSystemService<KeyguardManager>()?.isDeviceSecure == true) { - biometricPrompt.authenticate(promptInfo) + val validAuthenticators = Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK + val canAuth = BiometricManager.from(activity).canAuthenticate(validAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS + val deviceHasKeyguard = activity.getSystemService<KeyguardManager>()?.isDeviceSecure == true + if (canAuth || deviceHasKeyguard) { + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(dialogTitleRes)) + .setAllowedAuthenticators(validAuthenticators) + .build() + BiometricPrompt(activity, { handler.post(it) }, authCallback).authenticate(promptInfo) } else { callback(Result.HardwareUnavailableOrDisabled) } 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 96bf1b7e..561b8d99 100644 --- a/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt +++ b/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt @@ -9,6 +9,7 @@ import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.os.Build +import android.util.Base64 import android.util.TypedValue import android.view.View import android.view.autofill.AutofillManager @@ -42,6 +43,10 @@ fun String.splitLines(): Array<String> { return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() } +fun String.base64(): String { + return Base64.encodeToString(encodeToByteArray(), Base64.NO_WRAP) +} + val Context.clipboard get() = getSystemService<ClipboardManager>() fun FragmentActivity.snackbar( diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/PasswordItem.kt b/app/src/main/java/com/zeapo/pwdstore/utils/PasswordItem.kt index 016ee1aa..ef31324b 100644 --- a/app/src/main/java/com/zeapo/pwdstore/utils/PasswordItem.kt +++ b/app/src/main/java/com/zeapo/pwdstore/utils/PasswordItem.kt @@ -33,7 +33,7 @@ data class PasswordItem( } override fun toString(): String { - return name.replace(".gpg", "") + return name.replace("\\.gpg$".toRegex(), "") } override fun hashCode(): Int { 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 8a49f0e3..73660fca 100644 --- a/app/src/main/java/com/zeapo/pwdstore/utils/PasswordRepository.kt +++ b/app/src/main/java/com/zeapo/pwdstore/utils/PasswordRepository.kt @@ -4,6 +4,7 @@ */ package com.zeapo.pwdstore.utils +import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit import com.zeapo.pwdstore.Application @@ -31,6 +32,18 @@ open class PasswordRepository protected constructor() { p1.name.compareTo(p2.name, ignoreCase = true) }), + RECENTLY_USED(Comparator { p1: PasswordItem, p2: PasswordItem -> + val recentHistory = Application.instance.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) + val timeP1 = recentHistory.getString(p1.file.absolutePath.base64()) + val timeP2 = recentHistory.getString(p2.file.absolutePath.base64()) + when { + timeP1 != null && timeP2 != null -> timeP2.compareTo(timeP1) + timeP1 != null && timeP2 == null -> return@Comparator -1 + timeP1 == null && timeP2 != null -> return@Comparator 1 + else -> p1.name.compareTo(p2.name, ignoreCase = true) + } + }), + FILE_FIRST(Comparator { p1: PasswordItem, p2: PasswordItem -> (p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true) }); @@ -213,12 +226,10 @@ 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(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false) + val showHidden = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false) if (passList.size == 0) return passwordList - if (showHiddenDirs) { - passList.filter { !(it.isFile && it.isHidden) }.toCollection(passList.apply { clear() }) - } else { + if (!showHidden) { passList.filter { !it.isHidden }.toCollection(passList.apply { clear() }) } passList.forEach { file -> @@ -231,47 +242,5 @@ open class PasswordRepository protected constructor() { passwordList.sortWith(sortOrder.comparator) return passwordList } - - /** - * Sets the git user name - * - * @param username username - */ - @JvmStatic - fun setGitAuthorName(username: String) { - setStringConfig("user", null, "name", username) - } - - /** - * Sets the git user email - * - * @param email email - */ - @JvmStatic - fun setGitAuthorEmail(email: String) { - setStringConfig("user", null, "email", email) - } - - /** - * Sets a git config value - * - * @param section config section name - * @param subsection config subsection name - * @param name config name - * @param value the value to be set - */ - @JvmStatic - @Suppress("SameParameterValue") - private fun setStringConfig(section: String, subsection: String?, name: String, value: String) { - if (isInitialized) { - val config = repository!!.config - config.setString(section, subsection, name, value) - try { - config.save() - } catch (e: Exception) { - e.printStackTrace() - } - } - } } } diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt b/app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt index 05fb9326..bcda4505 100644 --- a/app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt +++ b/app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt @@ -28,15 +28,21 @@ object PreferenceKeys { const val GIT_EXTERNAL = "git_external" const val GIT_EXTERNAL_REPO = "git_external_repo" const val GIT_REMOTE_AUTH = "git_remote_auth" + @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_LOCATION = "git_remote_location" + @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_PORT = "git_remote_port" + + @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_PROTOCOL = "git_remote_protocol" const val GIT_DELETE_REPO = "git_delete_repo" + @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_SERVER = "git_remote_server" const val GIT_REMOTE_URL = "git_remote_url" + @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_USERNAME = "git_remote_username" const val GIT_SERVER_INFO = "git_server_info" @@ -54,7 +60,13 @@ object PreferenceKeys { const val REPO_CHANGED = "repo_changed" const val SEARCH_ON_START = "search_on_start" const val SHOW_EXTRA_CONTENT = "show_extra_content" + + @Deprecated( + message = "Use SHOW_HIDDEN_CONTENTS instead", + replaceWith = ReplaceWith("PreferenceKeys.SHOW_HIDDEN_CONTENTS") + ) const val SHOW_HIDDEN_FOLDERS = "show_hidden_folders" + const val SHOW_HIDDEN_CONTENTS = "show_hidden_contents" const val SORT_ORDER = "sort_order" const val SHOW_PASSWORD = "show_password" const val SSH_KEY = "ssh_key" diff --git a/app/src/main/res/layout/activity_git_clone.xml b/app/src/main/res/layout/activity_git_clone.xml index 7c59572c..b7ec6fc1 100644 --- a/app/src/main/res/layout/activity_git_clone.xml +++ b/app/src/main/res/layout/activity_git_clone.xml @@ -29,42 +29,6 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/label_server_protocol" - style="@style/TextAppearance.MaterialComponents.Headline6" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:text="@string/server_protocol" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/server_label" /> - - <com.google.android.material.button.MaterialButtonToggleGroup - android:id="@+id/clone_protocol_group" - style="@style/TextAppearance.MaterialComponents.Headline1" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_margin="8dp" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/label_server_protocol" - app:selectionRequired="true" - app:singleSelection="true"> - - <com.google.android.material.button.MaterialButton - android:id="@+id/clone_protocol_ssh" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/clone_protocol_ssh" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/clone_protocol_https" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/clone_protocol_https" /> - </com.google.android.material.button.MaterialButtonToggleGroup> - <com.google.android.material.textfield.TextInputLayout android:id="@+id/label_server_url" android:layout_width="0dp" @@ -73,15 +37,15 @@ android:hint="@string/server_url" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/clone_protocol_group"> + app:layout_constraintTop_toBottomOf="@id/server_label"> <com.google.android.material.textfield.TextInputEditText android:id="@+id/server_url" android:layout_width="match_parent" android:layout_height="wrap_content" android:imeOptions="actionNext" - android:nextFocusForward="@id/server_branch" - android:inputType="textWebEmailAddress" /> + android:inputType="textWebEmailAddress" + android:nextFocusForward="@id/server_branch" /> </com.google.android.material.textfield.TextInputLayout> @@ -91,21 +55,21 @@ android:layout_height="wrap_content" android:layout_margin="8dp" android:hint="@string/server_branch" - app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/label_server_url"> <com.google.android.material.textfield.TextInputEditText android:id="@+id/server_branch" - android:imeOptions="actionDone" android:layout_width="match_parent" - android:inputType="textNoSuggestions" - android:layout_height="wrap_content" /> + android:layout_height="wrap_content" + android:imeOptions="actionDone" + android:inputType="textNoSuggestions" /> </com.google.android.material.textfield.TextInputLayout> <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/label_connection_mode" + android:id="@+id/label_auth_mode" style="@style/TextAppearance.MaterialComponents.Headline6" android:layout_width="wrap_content" android:layout_height="wrap_content" @@ -117,31 +81,31 @@ app:layout_constraintTop_toBottomOf="@id/label_server_branch" /> <com.google.android.material.button.MaterialButtonToggleGroup - android:id="@+id/connection_mode_group" + android:id="@+id/auth_mode_group" style="@style/TextAppearance.MaterialComponents.Headline1" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="8dp" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/label_connection_mode" + app:layout_constraintTop_toBottomOf="@id/label_auth_mode" app:singleSelection="true"> <com.google.android.material.button.MaterialButton - android:id="@+id/connection_mode_ssh_key" + android:id="@+id/auth_mode_ssh_key" style="?attr/materialButtonOutlinedStyle" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/connection_mode_ssh_key" /> <com.google.android.material.button.MaterialButton - android:id="@+id/connection_mode_password" + android:id="@+id/auth_mode_password" style="?attr/materialButtonOutlinedStyle" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/connection_mode_basic_authentication" /> <com.google.android.material.button.MaterialButton - android:id="@+id/connection_mode_open_keychain" + android:id="@+id/auth_mode_open_keychain" style="?attr/materialButtonOutlinedStyle" android:layout_width="wrap_content" android:layout_height="wrap_content" @@ -155,6 +119,6 @@ android:layout_marginTop="8dp" android:text="@string/crypto_save" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@id/connection_mode_group" /> + app:layout_constraintTop_toBottomOf="@id/auth_mode_group" /> </androidx.constraintlayout.widget.ConstraintLayout> </ScrollView> diff --git a/app/src/main/res/layout/password_creation_activity.xml b/app/src/main/res/layout/password_creation_activity.xml index 9440d128..8e2f42b1 100644 --- a/app/src/main/res/layout/password_creation_activity.xml +++ b/app/src/main/res/layout/password_creation_activity.xml @@ -3,118 +3,125 @@ ~ SPDX-License-Identifier: GPL-3.0-only --> -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" + +<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - android:orientation="vertical" - android:padding="@dimen/activity_horizontal_margin" + android:fillViewport="false" tools:context="com.zeapo.pwdstore.crypto.PasswordCreationActivity"> - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/directory_input_layout" + <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_gravity="center_vertical" - android:layout_margin="8dp" - android:enabled="false" - android:hint="@string/directory_hint" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent"> + android:orientation="vertical" + android:padding="@dimen/activity_horizontal_margin"> - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/directory" + <com.google.android.material.textfield.TextInputLayout + android:id="@+id/directory_input_layout" android:layout_width="match_parent" android:layout_height="wrap_content" - android:imeOptions="actionNext" - android:inputType="textNoSuggestions" - android:nextFocusForward="@id/password" - tools:text="CATEGORY HERE" /> - </com.google.android.material.textfield.TextInputLayout> + android:layout_gravity="center_vertical" + android:layout_margin="8dp" + android:enabled="false" + android:hint="@string/directory_hint" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/name_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_gravity="center_vertical" - android:layout_margin="8dp" - android:hint="@string/crypto_name_hint" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/directory_input_layout"> + <com.google.android.material.textfield.TextInputEditText + android:id="@+id/directory" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:imeOptions="actionNext" + android:inputType="textNoSuggestions" + android:nextFocusForward="@id/password" + tools:text="CATEGORY HERE" /> + </com.google.android.material.textfield.TextInputLayout> - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/filename" + <com.google.android.material.textfield.TextInputLayout + android:id="@+id/name_input_layout" android:layout_width="match_parent" android:layout_height="wrap_content" - android:imeOptions="actionNext" - android:inputType="textNoSuggestions" - android:nextFocusForward="@id/password" /> - </com.google.android.material.textfield.TextInputLayout> + android:layout_gravity="center_vertical" + android:layout_margin="8dp" + android:hint="@string/crypto_name_hint" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/directory_input_layout"> - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/password_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:hint="@string/crypto_pass_label" - app:endIconMode="password_toggle" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/name_input_layout"> + <com.google.android.material.textfield.TextInputEditText + android:id="@+id/filename" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:imeOptions="actionNext" + android:inputType="textNoSuggestions" + android:nextFocusForward="@id/password" /> + </com.google.android.material.textfield.TextInputLayout> - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/password" + <com.google.android.material.textfield.TextInputLayout + android:id="@+id/password_input_layout" android:layout_width="match_parent" android:layout_height="wrap_content" - android:imeOptions="actionDone" - android:inputType="textVisiblePassword" /> - </com.google.android.material.textfield.TextInputLayout> + android:layout_margin="8dp" + android:hint="@string/crypto_pass_label" + app:endIconMode="password_toggle" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/name_input_layout"> - <com.google.android.material.button.MaterialButton - android:id="@+id/generate_password" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:text="@string/pwd_generate_button" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@id/password_input_layout" /> + <com.google.android.material.textfield.TextInputEditText + android:id="@+id/password" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:imeOptions="actionDone" + android:inputType="textVisiblePassword" /> + </com.google.android.material.textfield.TextInputLayout> - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/extra_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:hint="@string/crypto_extra_label" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/generate_password"> + <com.google.android.material.button.MaterialButton + android:id="@+id/generate_password" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_margin="8dp" + android:text="@string/pwd_generate_button" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toBottomOf="@id/password_input_layout" /> - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/extra_content" + <com.google.android.material.textfield.TextInputLayout + android:id="@+id/extra_input_layout" android:layout_width="match_parent" android:layout_height="wrap_content" - android:inputType="textMultiLine|textVisiblePassword" /> + android:layout_margin="8dp" + android:hint="@string/crypto_extra_label" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/generate_password"> - </com.google.android.material.textfield.TextInputLayout> + <com.google.android.material.textfield.TextInputEditText + android:id="@+id/extra_content" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:inputType="textMultiLine|textVisiblePassword" /> - <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.textfield.TextInputLayout> - <com.google.android.material.switchmaterial.SwitchMaterial - android:id="@+id/encrypt_username" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:text="@string/crypto_encrypt_username_label" - android:visibility="gone" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/otp_import_button" - tools:visibility="visible" /> -</androidx.constraintlayout.widget.ConstraintLayout> + <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" + android:layout_height="wrap_content" + android:layout_margin="8dp" + android:text="@string/crypto_encrypt_username_label" + android:visibility="gone" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/otp_import_button" + tools:visibility="visible" /> + </androidx.constraintlayout.widget.ConstraintLayout> +</ScrollView> diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 79edf2d9..a7f3e046 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -269,8 +269,6 @@ <string name="access_sdcard_text">O local do armazenamento está em seu cartão SD ou armazenamento interno, mas o aplicativo não tem permissão para acessá-lo.</string> <string name="your_public_key">Sua chave pública</string> <string name="error_generate_ssh_key">Erro ao tentar gerar a chave SSH</string> - <string name="pref_show_hidden_title">Mostrar pastas ocultas</string> - <string name="pref_show_hidden_summary">Incluir diretórios ocultos na lista de senhas</string> <string name="title_create_folder">Criar pasta</string> <string name="title_rename_folder">Renomear pasta</string> <string name="message_category_error_empty_field">O nome da categoria não pode ser vazio</string> diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 596e1c14..3fab8cf9 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -251,8 +251,6 @@ <string name="ssh_openkeystore_clear_keyid">Очистить сохраненный SSH Key идентификатор OpenKystortore</string> <string name="your_public_key">Ваш публичный ключ</string> <string name="error_generate_ssh_key">Возникла ошибка при попытке генерации ssh ключа</string> - <string name="pref_show_hidden_title">Показать скрытые папки</string> - <string name="pref_show_hidden_summary">Включить скрытые директории в список паролей</string> <string name="title_create_folder">Создать папку</string> <string name="button_create">Создать</string> <string name="pref_search_on_start">Открыть поиск на старте</string> diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 6b5edf6c..2b320f31 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -8,11 +8,13 @@ <item>@string/pref_folder_first_sort_order</item> <item>@string/pref_file_first_sort_order</item> <item>@string/pref_type_independent_sort_order</item> + <item>@string/pref_recently_used_sort_order</item> </string-array> <string-array name="sort_order_values"> <item>FOLDER_FIRST</item> <item>FILE_FIRST</item> <item>INDEPENDENT</item> + <item>RECENTLY_USED</item> </string-array> <string-array name="capitalization_type_values"> <item>lowercase</item> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a7a9c141..a6e33047 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -140,6 +140,7 @@ <string name="pref_folder_first_sort_order">Folders first</string> <string name="pref_file_first_sort_order">Files first</string> <string name="pref_type_independent_sort_order">Type independent</string> + <string name="pref_recently_used_sort_order">Recently used</string> <string name="pref_autofill_title">Autofill</string> <string name="pref_autofill_enable_title">Enable Autofill</string> <string name="pref_autofill_enable_msg">Tap OK to go to Accessibility settings. There, tap Password Store under Services then tap the switch in the top right to turn it on or off.</string> @@ -301,8 +302,8 @@ <string name="access_sdcard_text">The store location is in your SD Card or Internal storage, but the app does not have permission to access it.</string> <string name="your_public_key">Your public key</string> <string name="error_generate_ssh_key">Error while trying to generate the ssh-key</string> - <string name="pref_show_hidden_title">Show hidden folders</string> - <string name="pref_show_hidden_summary">Include hidden directories in the password list</string> + <string name="pref_show_hidden_title">Show all files and folders</string> + <string name="pref_show_hidden_summary">Include non-password files and directories in the password list</string> <string name="title_create_folder">Create folder</string> <string name="title_rename_folder">Rename folder</string> <string name="message_category_error_empty_field">Category name can\'t be empty</string> @@ -327,6 +328,9 @@ <string name="connection_mode_openkeychain" translatable="false">OpenKeychain</string> <string name="git_server_config_save_success">Successfully saved configuration</string> <string name="git_server_config_save_error">The provided repository URL is not valid</string> + <string name="git_server_config_save_missing_username_https">Please specify the HTTPS username in the form https://username@example.com/…</string> + <string name="git_server_config_save_missing_username_ssh">Please specify the SSH username in the form username@example.com:…</string> + <string name="git_server_config_save_auth_mode_mismatch">Valid authentication modes for %1$s: %2$s</string> <string name="git_config_error_hostname_empty">empty hostname</string> <string name="git_config_error_generic">please verify your settings and try again</string> <string name="git_config_error_nonnumeric_port">port must be numeric</string> diff --git a/app/src/main/res/xml/oreo_autofill_service.xml b/app/src/main/res/xml/oreo_autofill_service.xml index 8b76c803..7e761e25 100644 --- a/app/src/main/res/xml/oreo_autofill_service.xml +++ b/app/src/main/res/xml/oreo_autofill_service.xml @@ -14,6 +14,7 @@ <compatibility-package android:name="com.microsoft.emmx" /> <compatibility-package android:name="com.opera.mini.native" /> <compatibility-package android:name="com.opera.mini.native.beta" /> + <compatibility-package android:name="org.bromite.bromite" /> <compatibility-package android:name="org.mozilla.fennec_fdroid" android:maxLongVersionCode="679999" /> @@ -23,4 +24,5 @@ <compatibility-package android:name="org.mozilla.firefox_beta" android:maxLongVersionCode="679999" /> + <compatibility-package android:name="org.ungoogled.chromium.stable" /> </autofill-service> diff --git a/app/src/main/res/xml/preference.xml b/app/src/main/res/xml/preference.xml index 50300b5b..1d82df18 100644 --- a/app/src/main/res/xml/preference.xml +++ b/app/src/main/res/xml/preference.xml @@ -127,7 +127,7 @@ app:title="@string/pref_search_on_start" /> <CheckBoxPreference app:defaultValue="false" - app:key="show_hidden_folders" + app:key="show_hidden_contents" app:persistent="true" app:summary="@string/pref_show_hidden_summary" app:title="@string/pref_show_hidden_title" /> diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 23c4c264..61145e3f 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -23,18 +23,20 @@ object Dependencies { object AndroidX { - const val activity_ktx = "androidx.activity:activity-ktx:1.2.0-alpha07" + private const val lifecycleVersion = "2.3.0-alpha07" + + const val activity_ktx = "androidx.activity:activity-ktx:1.2.0-alpha08" const val annotation = "androidx.annotation:annotation:1.2.0-alpha01" - const val autofill = "androidx.autofill:autofill:1.1.0-alpha01" - const val appcompat = "androidx.appcompat:appcompat:1.3.0-alpha01" - const val biometric = "androidx.biometric:biometric:1.1.0-alpha01" + const val autofill = "androidx.autofill:autofill:1.1.0-alpha02" + const val appcompat = "androidx.appcompat:appcompat:1.3.0-alpha02" + const val biometric = "androidx.biometric:biometric:1.1.0-alpha02" const val constraint_layout = "androidx.constraintlayout:constraintlayout:2.0.0-rc1" - const val core_ktx = "androidx.core:core-ktx:1.5.0-alpha01" + const val core_ktx = "androidx.core:core-ktx:1.5.0-alpha02" const val documentfile = "androidx.documentfile:documentfile:1.0.1" - const val fragment_ktx = "androidx.fragment:fragment-ktx:1.3.0-alpha07" - const val lifecycle_common = "androidx.lifecycle:lifecycle-common-java8:2.3.0-alpha06" - const val lifecycle_livedata_ktx = "androidx.lifecycle:lifecycle-livedata-ktx:2.3.0-alpha06" - const val lifecycle_viewmodel_ktx = "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.0-alpha06" + const val fragment_ktx = "androidx.fragment:fragment-ktx:1.3.0-alpha08" + const val lifecycle_common = "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" + const val lifecycle_livedata_ktx = "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" + const val lifecycle_viewmodel_ktx = "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" const val material = "com.google.android.material:material:1.3.0-alpha02" const val preference = "androidx.preference:preference:1.1.1" const val recycler_view = "androidx.recyclerview:recyclerview:1.2.0-alpha05" @@ -62,7 +64,7 @@ object Dependencies { const val ssh_auth = "org.sufficientlysecure:sshauthentication-api:1.0" const val timber = "com.jakewharton.timber:timber:4.7.1" const val timberkt = "com.github.ajalt:timberkt:1.5.1" - const val whatthestack = "com.github.haroldadmin:WhatTheStack:0.0.4" + const val whatthestack = "com.github.haroldadmin:WhatTheStack:0.0.5" } object NonFree { |