From 549ee790d3e52bc62565ddf92e6a556e98b5195e Mon Sep 17 00:00:00 2001 From: Harsh Shandilya Date: Fri, 15 Jul 2022 00:53:48 +0530 Subject: all: re-do package structure yet again --- app/src/main/java/app/passwordstore/Application.kt | 93 +++ .../passwordstore/data/crypto/CryptoRepository.kt | 55 ++ .../app/passwordstore/data/password/FieldItem.kt | 43 ++ .../passwordstore/data/password/PasswordItem.kt | 77 +++ .../passwordstore/data/repo/PasswordRepository.kt | 176 ++++++ .../injection/context/ContextModule.kt | 27 + .../injection/context/FilesDirPath.kt | 7 + .../injection/coroutines/DispatcherModule.kt | 19 + .../injection/crypto/CryptoHandlerModule.kt | 18 + .../injection/crypto/KeyManagerModule.kt | 39 ++ .../injection/prefs/GitPreferences.kt | 9 + .../prefs/PasswordGeneratorPreferences.kt | 10 + .../injection/prefs/PreferenceModule.kt | 60 ++ .../injection/prefs/ProxyPreferences.kt | 9 + .../injection/prefs/SettingsPreferences.kt | 5 + .../injection/pwgen/DicewareModule.kt | 55 ++ .../app/passwordstore/injection/totp/TotpModule.kt | 19 + .../passwordstore/ui/adapters/FieldItemAdapter.kt | 92 +++ .../ui/adapters/PasswordItemRecyclerAdapter.kt | 94 +++ .../ui/autofill/AutofillDecryptActivity.kt | 269 +++++++++ .../ui/autofill/AutofillDecryptActivityV2.kt | 180 ++++++ .../ui/autofill/AutofillFilterView.kt | 241 ++++++++ .../autofill/AutofillPublisherChangedActivity.kt | 135 +++++ .../ui/autofill/AutofillSaveActivity.kt | 173 ++++++ .../ui/autofill/PasswordViewHolder.kt | 16 + .../app/passwordstore/ui/crypto/BasePgpActivity.kt | 290 ++++++++++ .../app/passwordstore/ui/crypto/DecryptActivity.kt | 227 ++++++++ .../passwordstore/ui/crypto/DecryptActivityV2.kt | 209 +++++++ .../passwordstore/ui/crypto/GetKeyIdsActivity.kt | 78 +++ .../ui/crypto/PasswordCreationActivity.kt | 617 ++++++++++++++++++++ .../ui/crypto/PasswordCreationActivityV2.kt | 479 +++++++++++++++ .../app/passwordstore/ui/crypto/PasswordDialog.kt | 68 +++ .../passwordstore/ui/dialogs/BasicBottomSheet.kt | 166 ++++++ .../DicewarePasswordGeneratorDialogFragment.kt | 90 +++ .../ui/dialogs/FolderCreationDialogFragment.kt | 110 ++++ .../ui/dialogs/ItemCreationBottomSheet.kt | 77 +++ .../ui/dialogs/OtpImportDialogFragment.kt | 47 ++ .../ui/dialogs/PasswordGeneratorDialogFragment.kt | 149 +++++ .../ui/folderselect/SelectFolderActivity.kt | 65 +++ .../ui/folderselect/SelectFolderFragment.kt | 86 +++ .../passwordstore/ui/git/base/BaseGitActivity.kt | 185 ++++++ .../ui/git/config/GitConfigActivity.kt | 159 +++++ .../ui/git/config/GitServerConfigActivity.kt | 305 ++++++++++ .../app/passwordstore/ui/git/log/GitLogActivity.kt | 49 ++ .../app/passwordstore/ui/git/log/GitLogAdapter.kt | 59 ++ .../app/passwordstore/ui/main/LaunchActivity.kt | 87 +++ .../ui/onboarding/activity/OnboardingActivity.kt | 26 + .../ui/onboarding/fragments/CloneFragment.kt | 78 +++ .../onboarding/fragments/KeySelectionFragment.kt | 74 +++ .../ui/onboarding/fragments/WelcomeFragment.kt | 33 ++ .../passwordstore/ui/passwords/PasswordFragment.kt | 382 ++++++++++++ .../passwordstore/ui/passwords/PasswordStore.kt | 639 +++++++++++++++++++++ .../passwordstore/ui/pgp/PGPKeyImportActivity.kt | 70 +++ .../ui/proxy/ProxySelectorActivity.kt | 94 +++ .../passwordstore/ui/settings/AutofillSettings.kt | 133 +++++ .../passwordstore/ui/settings/GeneralSettings.kt | 110 ++++ .../app/passwordstore/ui/settings/MiscSettings.kt | 81 +++ .../app/passwordstore/ui/settings/PGPSettings.kt | 37 ++ .../passwordstore/ui/settings/PasswordSettings.kt | 53 ++ .../ui/settings/RepositorySettings.kt | 194 +++++++ .../passwordstore/ui/settings/SettingsActivity.kt | 106 ++++ .../passwordstore/ui/settings/SettingsProvider.kt | 15 + .../ui/sshkeygen/ShowSshKeyFragment.kt | 39 ++ .../ui/sshkeygen/SshKeyGenActivity.kt | 172 ++++++ .../ui/sshkeygen/SshKeyImportActivity.kt | 66 +++ .../app/passwordstore/ui/util/OnOffItemAnimator.kt | 71 +++ .../util/auth/BiometricAuthenticator.kt | 131 +++++ .../util/autofill/Api30AutofillResponseBuilder.kt | 236 ++++++++ .../passwordstore/util/autofill/AutofillMatcher.kt | 202 +++++++ .../util/autofill/AutofillPreferences.kt | 148 +++++ .../util/autofill/AutofillResponseBuilder.kt | 233 ++++++++ .../util/autofill/AutofillViewUtils.kt | 120 ++++ .../app/passwordstore/util/crypto/GpgIdentifier.kt | 42 ++ .../util/extensions/AndroidExtensions.kt | 133 +++++ .../passwordstore/util/extensions/Extensions.kt | 98 ++++ .../util/extensions/FragmentExtensions.kt | 41 ++ .../util/extensions/FragmentViewBindingDelegate.kt | 70 +++ .../app/passwordstore/util/features/Feature.kt | 23 + .../app/passwordstore/util/features/Features.kt | 21 + .../app/passwordstore/util/git/ErrorMessages.kt | 66 +++ .../passwordstore/util/git/GitCommandExecutor.kt | 133 +++++ .../java/app/passwordstore/util/git/GitCommit.kt | 23 + .../java/app/passwordstore/util/git/GitLogModel.kt | 60 ++ .../util/git/operation/BreakOutOfDetached.kt | 66 +++ .../util/git/operation/CloneOperation.kt | 24 + .../util/git/operation/CredentialFinder.kt | 116 ++++ .../util/git/operation/GcOperation.kt | 22 + .../util/git/operation/GitOperation.kt | 245 ++++++++ .../util/git/operation/PullOperation.kt | 35 ++ .../util/git/operation/PushOperation.kt | 17 + .../util/git/operation/ResetToRemoteOperation.kt | 27 + .../util/git/operation/SyncOperation.kt | 27 + .../util/git/sshj/ContinuationContainerActivity.kt | 34 ++ .../util/git/sshj/OpenKeychainKeyProvider.kt | 225 ++++++++ .../sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt | 103 ++++ .../java/app/passwordstore/util/git/sshj/SshKey.kt | 372 ++++++++++++ .../app/passwordstore/util/git/sshj/SshjConfig.kt | 289 ++++++++++ .../util/git/sshj/SshjSessionFactory.kt | 218 +++++++ .../app/passwordstore/util/proxy/ProxyUtils.kt | 71 +++ .../util/services/ClipboardService.kt | 206 +++++++ .../util/services/OreoAutofillService.kt | 172 ++++++ .../util/services/PasswordExportService.kt | 159 +++++ .../app/passwordstore/util/settings/GitSettings.kt | 186 ++++++ .../app/passwordstore/util/settings/Migrations.kt | 154 +++++ .../util/settings/PasswordSortOrder.kt | 53 ++ .../passwordstore/util/settings/PreferenceKeys.kt | 88 +++ .../util/shortcuts/ShortcutHandler.kt | 106 ++++ .../app/passwordstore/util/totp/UriTotpFinder.kt | 53 ++ .../viewmodel/SearchableRepositoryViewModel.kt | 473 +++++++++++++++ app/src/main/java/dev/msfjarvis/aps/Application.kt | 93 --- .../msfjarvis/aps/data/crypto/CryptoRepository.kt | 55 -- .../dev/msfjarvis/aps/data/password/FieldItem.kt | 43 -- .../msfjarvis/aps/data/password/PasswordItem.kt | 77 --- .../msfjarvis/aps/data/repo/PasswordRepository.kt | 176 ------ .../aps/injection/context/ContextModule.kt | 27 - .../aps/injection/context/FilesDirPath.kt | 7 - .../aps/injection/coroutines/DispatcherModule.kt | 19 - .../aps/injection/crypto/CryptoHandlerModule.kt | 18 - .../aps/injection/crypto/KeyManagerModule.kt | 39 -- .../aps/injection/prefs/GitPreferences.kt | 9 - .../prefs/PasswordGeneratorPreferences.kt | 10 - .../aps/injection/prefs/PreferenceModule.kt | 60 -- .../aps/injection/prefs/ProxyPreferences.kt | 9 - .../aps/injection/prefs/SettingsPreferences.kt | 5 - .../aps/injection/pwgen/DicewareModule.kt | 55 -- .../dev/msfjarvis/aps/injection/totp/TotpModule.kt | 19 - .../msfjarvis/aps/ui/adapters/FieldItemAdapter.kt | 92 --- .../aps/ui/adapters/PasswordItemRecyclerAdapter.kt | 94 --- .../aps/ui/autofill/AutofillDecryptActivity.kt | 269 --------- .../aps/ui/autofill/AutofillDecryptActivityV2.kt | 180 ------ .../aps/ui/autofill/AutofillFilterView.kt | 241 -------- .../autofill/AutofillPublisherChangedActivity.kt | 135 ----- .../aps/ui/autofill/AutofillSaveActivity.kt | 173 ------ .../aps/ui/autofill/PasswordViewHolder.kt | 16 - .../dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt | 290 ---------- .../dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt | 227 -------- .../msfjarvis/aps/ui/crypto/DecryptActivityV2.kt | 209 ------- .../msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt | 78 --- .../aps/ui/crypto/PasswordCreationActivity.kt | 617 -------------------- .../aps/ui/crypto/PasswordCreationActivityV2.kt | 479 --------------- .../dev/msfjarvis/aps/ui/crypto/PasswordDialog.kt | 68 --- .../msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt | 166 ------ .../DicewarePasswordGeneratorDialogFragment.kt | 90 --- .../aps/ui/dialogs/FolderCreationDialogFragment.kt | 110 ---- .../aps/ui/dialogs/ItemCreationBottomSheet.kt | 77 --- .../aps/ui/dialogs/OtpImportDialogFragment.kt | 47 -- .../ui/dialogs/PasswordGeneratorDialogFragment.kt | 149 ----- .../aps/ui/folderselect/SelectFolderActivity.kt | 65 --- .../aps/ui/folderselect/SelectFolderFragment.kt | 86 --- .../msfjarvis/aps/ui/git/base/BaseGitActivity.kt | 185 ------ .../aps/ui/git/config/GitConfigActivity.kt | 159 ----- .../aps/ui/git/config/GitServerConfigActivity.kt | 305 ---------- .../dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt | 49 -- .../dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt | 59 -- .../dev/msfjarvis/aps/ui/main/LaunchActivity.kt | 87 --- .../ui/onboarding/activity/OnboardingActivity.kt | 26 - .../aps/ui/onboarding/fragments/CloneFragment.kt | 78 --- .../onboarding/fragments/KeySelectionFragment.kt | 74 --- .../aps/ui/onboarding/fragments/WelcomeFragment.kt | 33 -- .../msfjarvis/aps/ui/passwords/PasswordFragment.kt | 382 ------------ .../msfjarvis/aps/ui/passwords/PasswordStore.kt | 639 --------------------- .../msfjarvis/aps/ui/pgp/PGPKeyImportActivity.kt | 70 --- .../aps/ui/proxy/ProxySelectorActivity.kt | 94 --- .../msfjarvis/aps/ui/settings/AutofillSettings.kt | 133 ----- .../msfjarvis/aps/ui/settings/GeneralSettings.kt | 110 ---- .../dev/msfjarvis/aps/ui/settings/MiscSettings.kt | 81 --- .../dev/msfjarvis/aps/ui/settings/PGPSettings.kt | 37 -- .../msfjarvis/aps/ui/settings/PasswordSettings.kt | 53 -- .../aps/ui/settings/RepositorySettings.kt | 194 ------- .../msfjarvis/aps/ui/settings/SettingsActivity.kt | 106 ---- .../msfjarvis/aps/ui/settings/SettingsProvider.kt | 15 - .../aps/ui/sshkeygen/ShowSshKeyFragment.kt | 39 -- .../aps/ui/sshkeygen/SshKeyGenActivity.kt | 172 ------ .../aps/ui/sshkeygen/SshKeyImportActivity.kt | 66 --- .../dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt | 71 --- .../aps/util/auth/BiometricAuthenticator.kt | 131 ----- .../util/autofill/Api30AutofillResponseBuilder.kt | 236 -------- .../msfjarvis/aps/util/autofill/AutofillMatcher.kt | 202 ------- .../aps/util/autofill/AutofillPreferences.kt | 148 ----- .../aps/util/autofill/AutofillResponseBuilder.kt | 233 -------- .../aps/util/autofill/AutofillViewUtils.kt | 120 ---- .../dev/msfjarvis/aps/util/crypto/GpgIdentifier.kt | 42 -- .../aps/util/extensions/AndroidExtensions.kt | 133 ----- .../msfjarvis/aps/util/extensions/Extensions.kt | 98 ---- .../aps/util/extensions/FragmentExtensions.kt | 41 -- .../util/extensions/FragmentViewBindingDelegate.kt | 70 --- .../dev/msfjarvis/aps/util/features/Feature.kt | 23 - .../dev/msfjarvis/aps/util/features/Features.kt | 21 - .../dev/msfjarvis/aps/util/git/ErrorMessages.kt | 66 --- .../msfjarvis/aps/util/git/GitCommandExecutor.kt | 133 ----- .../java/dev/msfjarvis/aps/util/git/GitCommit.kt | 23 - .../java/dev/msfjarvis/aps/util/git/GitLogModel.kt | 60 -- .../aps/util/git/operation/BreakOutOfDetached.kt | 66 --- .../aps/util/git/operation/CloneOperation.kt | 24 - .../aps/util/git/operation/CredentialFinder.kt | 116 ---- .../aps/util/git/operation/GcOperation.kt | 22 - .../aps/util/git/operation/GitOperation.kt | 245 -------- .../aps/util/git/operation/PullOperation.kt | 35 -- .../aps/util/git/operation/PushOperation.kt | 17 - .../util/git/operation/ResetToRemoteOperation.kt | 27 - .../aps/util/git/operation/SyncOperation.kt | 27 - .../util/git/sshj/ContinuationContainerActivity.kt | 34 -- .../aps/util/git/sshj/OpenKeychainKeyProvider.kt | 225 -------- .../sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt | 103 ---- .../java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt | 372 ------------ .../dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt | 289 ---------- .../aps/util/git/sshj/SshjSessionFactory.kt | 218 ------- .../dev/msfjarvis/aps/util/proxy/ProxyUtils.kt | 71 --- .../aps/util/services/ClipboardService.kt | 206 ------- .../aps/util/services/OreoAutofillService.kt | 172 ------ .../aps/util/services/PasswordExportService.kt | 159 ----- .../dev/msfjarvis/aps/util/settings/GitSettings.kt | 186 ------ .../dev/msfjarvis/aps/util/settings/Migrations.kt | 154 ----- .../aps/util/settings/PasswordSortOrder.kt | 53 -- .../msfjarvis/aps/util/settings/PreferenceKeys.kt | 88 --- .../aps/util/shortcuts/ShortcutHandler.kt | 106 ---- .../dev/msfjarvis/aps/util/totp/UriTotpFinder.kt | 53 -- .../viewmodel/SearchableRepositoryViewModel.kt | 473 --------------- 218 files changed, 13317 insertions(+), 13317 deletions(-) create mode 100644 app/src/main/java/app/passwordstore/Application.kt create mode 100644 app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt create mode 100644 app/src/main/java/app/passwordstore/data/password/FieldItem.kt create mode 100644 app/src/main/java/app/passwordstore/data/password/PasswordItem.kt create mode 100644 app/src/main/java/app/passwordstore/data/repo/PasswordRepository.kt create mode 100644 app/src/main/java/app/passwordstore/injection/context/ContextModule.kt create mode 100644 app/src/main/java/app/passwordstore/injection/context/FilesDirPath.kt create mode 100644 app/src/main/java/app/passwordstore/injection/coroutines/DispatcherModule.kt create mode 100644 app/src/main/java/app/passwordstore/injection/crypto/CryptoHandlerModule.kt create mode 100644 app/src/main/java/app/passwordstore/injection/crypto/KeyManagerModule.kt create mode 100644 app/src/main/java/app/passwordstore/injection/prefs/GitPreferences.kt create mode 100644 app/src/main/java/app/passwordstore/injection/prefs/PasswordGeneratorPreferences.kt create mode 100644 app/src/main/java/app/passwordstore/injection/prefs/PreferenceModule.kt create mode 100644 app/src/main/java/app/passwordstore/injection/prefs/ProxyPreferences.kt create mode 100644 app/src/main/java/app/passwordstore/injection/prefs/SettingsPreferences.kt create mode 100644 app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt create mode 100644 app/src/main/java/app/passwordstore/injection/totp/TotpModule.kt create mode 100644 app/src/main/java/app/passwordstore/ui/adapters/FieldItemAdapter.kt create mode 100644 app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt create mode 100644 app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt create mode 100644 app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivityV2.kt create mode 100644 app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt create mode 100644 app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt create mode 100644 app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt create mode 100644 app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt create mode 100644 app/src/main/java/app/passwordstore/ui/crypto/BasePgpActivity.kt create mode 100644 app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt create mode 100644 app/src/main/java/app/passwordstore/ui/crypto/DecryptActivityV2.kt create mode 100644 app/src/main/java/app/passwordstore/ui/crypto/GetKeyIdsActivity.kt create mode 100644 app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt create mode 100644 app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivityV2.kt create mode 100644 app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt create mode 100644 app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt create mode 100644 app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt create mode 100644 app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt create mode 100644 app/src/main/java/app/passwordstore/ui/dialogs/ItemCreationBottomSheet.kt create mode 100644 app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt create mode 100644 app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt create mode 100644 app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt create mode 100644 app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt create mode 100644 app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt create mode 100644 app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt create mode 100644 app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt create mode 100644 app/src/main/java/app/passwordstore/ui/git/log/GitLogActivity.kt create mode 100644 app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt create mode 100644 app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt create mode 100644 app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt create mode 100644 app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt create mode 100644 app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt create mode 100644 app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt create mode 100644 app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt create mode 100644 app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt create mode 100644 app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt create mode 100644 app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt create mode 100644 app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt create mode 100644 app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt create mode 100644 app/src/main/java/app/passwordstore/ui/settings/MiscSettings.kt create mode 100644 app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt create mode 100644 app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt create mode 100644 app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt create mode 100644 app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt create mode 100644 app/src/main/java/app/passwordstore/ui/settings/SettingsProvider.kt create mode 100644 app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt create mode 100644 app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt create mode 100644 app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt create mode 100644 app/src/main/java/app/passwordstore/ui/util/OnOffItemAnimator.kt create mode 100644 app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt create mode 100644 app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt create mode 100644 app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt create mode 100644 app/src/main/java/app/passwordstore/util/autofill/AutofillPreferences.kt create mode 100644 app/src/main/java/app/passwordstore/util/autofill/AutofillResponseBuilder.kt create mode 100644 app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt create mode 100644 app/src/main/java/app/passwordstore/util/crypto/GpgIdentifier.kt create mode 100644 app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt create mode 100644 app/src/main/java/app/passwordstore/util/extensions/Extensions.kt create mode 100644 app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt create mode 100644 app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt create mode 100644 app/src/main/java/app/passwordstore/util/features/Feature.kt create mode 100644 app/src/main/java/app/passwordstore/util/features/Features.kt create mode 100644 app/src/main/java/app/passwordstore/util/git/ErrorMessages.kt create mode 100644 app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt create mode 100644 app/src/main/java/app/passwordstore/util/git/GitCommit.kt create mode 100644 app/src/main/java/app/passwordstore/util/git/GitLogModel.kt create mode 100644 app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt create mode 100644 app/src/main/java/app/passwordstore/util/git/operation/CloneOperation.kt create mode 100644 app/src/main/java/app/passwordstore/util/git/operation/CredentialFinder.kt create mode 100644 app/src/main/java/app/passwordstore/util/git/operation/GcOperation.kt create mode 100644 app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt create mode 100644 app/src/main/java/app/passwordstore/util/git/operation/PullOperation.kt create mode 100644 app/src/main/java/app/passwordstore/util/git/operation/PushOperation.kt create mode 100644 app/src/main/java/app/passwordstore/util/git/operation/ResetToRemoteOperation.kt create mode 100644 app/src/main/java/app/passwordstore/util/git/operation/SyncOperation.kt create mode 100644 app/src/main/java/app/passwordstore/util/git/sshj/ContinuationContainerActivity.kt create mode 100644 app/src/main/java/app/passwordstore/util/git/sshj/OpenKeychainKeyProvider.kt create mode 100644 app/src/main/java/app/passwordstore/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt create mode 100644 app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt create mode 100644 app/src/main/java/app/passwordstore/util/git/sshj/SshjConfig.kt create mode 100644 app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt create mode 100644 app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt create mode 100644 app/src/main/java/app/passwordstore/util/services/ClipboardService.kt create mode 100644 app/src/main/java/app/passwordstore/util/services/OreoAutofillService.kt create mode 100644 app/src/main/java/app/passwordstore/util/services/PasswordExportService.kt create mode 100644 app/src/main/java/app/passwordstore/util/settings/GitSettings.kt create mode 100644 app/src/main/java/app/passwordstore/util/settings/Migrations.kt create mode 100644 app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt create mode 100644 app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt create mode 100644 app/src/main/java/app/passwordstore/util/shortcuts/ShortcutHandler.kt create mode 100644 app/src/main/java/app/passwordstore/util/totp/UriTotpFinder.kt create mode 100644 app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/Application.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/data/crypto/CryptoRepository.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/data/password/FieldItem.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/data/password/PasswordItem.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/data/repo/PasswordRepository.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/injection/context/ContextModule.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/injection/context/FilesDirPath.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/injection/coroutines/DispatcherModule.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/injection/crypto/CryptoHandlerModule.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/injection/crypto/KeyManagerModule.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/injection/prefs/GitPreferences.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/injection/prefs/PasswordGeneratorPreferences.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/injection/prefs/PreferenceModule.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/injection/prefs/ProxyPreferences.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/injection/prefs/SettingsPreferences.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/injection/pwgen/DicewareModule.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/injection/totp/TotpModule.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/adapters/FieldItemAdapter.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/adapters/PasswordItemRecyclerAdapter.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivityV2.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterView.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillPublisherChangedActivity.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillSaveActivity.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/autofill/PasswordViewHolder.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivityV2.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivityV2.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordDialog.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/dialogs/FolderCreationDialogFragment.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/dialogs/ItemCreationBottomSheet.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/dialogs/OtpImportDialogFragment.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderActivity.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderFragment.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/git/base/BaseGitActivity.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitConfigActivity.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitServerConfigActivity.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/main/LaunchActivity.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/onboarding/activity/OnboardingActivity.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/CloneFragment.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/KeySelectionFragment.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/WelcomeFragment.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/pgp/PGPKeyImportActivity.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/proxy/ProxySelectorActivity.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/settings/AutofillSettings.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/settings/GeneralSettings.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/settings/MiscSettings.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/settings/PGPSettings.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/settings/PasswordSettings.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/settings/RepositorySettings.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsActivity.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsProvider.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/ShowSshKeyFragment.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyGenActivity.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyImportActivity.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/crypto/GpgIdentifier.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/features/Feature.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/features/Features.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/GitCommit.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/GcOperation.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/shortcuts/ShortcutHandler.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt (limited to 'app/src/main/java') diff --git a/app/src/main/java/app/passwordstore/Application.kt b/app/src/main/java/app/passwordstore/Application.kt new file mode 100644 index 00000000..05bffbfb --- /dev/null +++ b/app/src/main/java/app/passwordstore/Application.kt @@ -0,0 +1,93 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore + +import android.content.SharedPreferences +import androidx.appcompat.app.AppCompatDelegate +import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY +import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM +import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO +import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES +import app.passwordstore.injection.context.FilesDirPath +import app.passwordstore.injection.prefs.SettingsPreferences +import app.passwordstore.util.extensions.getString +import app.passwordstore.util.features.Feature +import app.passwordstore.util.features.Features +import app.passwordstore.util.git.sshj.setUpBouncyCastleForSshj +import app.passwordstore.util.proxy.ProxyUtils +import app.passwordstore.util.settings.GitSettings +import app.passwordstore.util.settings.PreferenceKeys +import app.passwordstore.util.settings.runMigrations +import com.google.android.material.color.DynamicColors +import dagger.hilt.android.HiltAndroidApp +import io.sentry.Sentry +import io.sentry.protocol.User +import javax.inject.Inject +import logcat.AndroidLogcatLogger +import logcat.LogPriority.DEBUG +import logcat.LogcatLogger + +@Suppress("Unused") +@HiltAndroidApp +class Application : android.app.Application(), SharedPreferences.OnSharedPreferenceChangeListener { + + @Inject @SettingsPreferences lateinit var prefs: SharedPreferences + @Inject @FilesDirPath lateinit var filesDirPath: String + @Inject lateinit var proxyUtils: ProxyUtils + @Inject lateinit var gitSettings: GitSettings + @Inject lateinit var features: Features + + override fun onCreate() { + super.onCreate() + instance = this + if ( + BuildConfig.ENABLE_DEBUG_FEATURES || + prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false) + ) { + LogcatLogger.install(AndroidLogcatLogger(DEBUG)) + } + prefs.registerOnSharedPreferenceChangeListener(this) + setNightMode() + setUpBouncyCastleForSshj() + runMigrations(filesDirPath, prefs, gitSettings) + proxyUtils.setDefaultProxy() + DynamicColors.applyToActivitiesIfAvailable(this) + Sentry.configureScope { scope -> + val user = User() + user.others = + Feature.VALUES.associate { feature -> + "features.${feature.configKey}" to features.isEnabled(feature).toString() + } + scope.user = user + } + } + + override fun onTerminate() { + prefs.unregisterOnSharedPreferenceChangeListener(this) + super.onTerminate() + } + + override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String) { + if (key == PreferenceKeys.APP_THEME) { + setNightMode() + } + } + + private fun setNightMode() { + AppCompatDelegate.setDefaultNightMode( + when (prefs.getString(PreferenceKeys.APP_THEME) ?: getString(R.string.app_theme_def)) { + "light" -> MODE_NIGHT_NO + "dark" -> MODE_NIGHT_YES + "follow_system" -> MODE_NIGHT_FOLLOW_SYSTEM + else -> MODE_NIGHT_AUTO_BATTERY + } + ) + } + + companion object { + + lateinit var instance: Application + } +} diff --git a/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt b/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt new file mode 100644 index 00000000..6c214fd1 --- /dev/null +++ b/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt @@ -0,0 +1,55 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.data.crypto + +import app.passwordstore.crypto.PGPKeyManager +import app.passwordstore.crypto.PGPainlessCryptoHandler +import app.passwordstore.util.extensions.isOk +import com.github.michaelbull.result.unwrap +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class CryptoRepository +@Inject +constructor( + private val pgpKeyManager: PGPKeyManager, + private val pgpCryptoHandler: PGPainlessCryptoHandler, +) { + + suspend fun decrypt( + password: String, + message: ByteArrayInputStream, + out: ByteArrayOutputStream, + ) { + withContext(Dispatchers.IO) { decryptPgp(password, message, out) } + } + + suspend fun encrypt(content: ByteArrayInputStream, out: ByteArrayOutputStream) { + withContext(Dispatchers.IO) { encryptPgp(content, out) } + } + + private suspend fun decryptPgp( + password: String, + message: ByteArrayInputStream, + out: ByteArrayOutputStream, + ) { + val keys = pgpKeyManager.getAllKeys().unwrap() + // Iterates through the keys until the first successful decryption, then returns. + keys.firstOrNull { key -> pgpCryptoHandler.decrypt(key, password, message, out).isOk() } + } + + private suspend fun encryptPgp(content: ByteArrayInputStream, out: ByteArrayOutputStream) { + val keys = pgpKeyManager.getAllKeys().unwrap() + pgpCryptoHandler.encrypt( + keys, + content, + out, + ) + } +} diff --git a/app/src/main/java/app/passwordstore/data/password/FieldItem.kt b/app/src/main/java/app/passwordstore/data/password/FieldItem.kt new file mode 100644 index 00000000..49ed4c82 --- /dev/null +++ b/app/src/main/java/app/passwordstore/data/password/FieldItem.kt @@ -0,0 +1,43 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.data.password + +import app.passwordstore.data.passfile.Totp +import kotlin.time.ExperimentalTime + +@OptIn(ExperimentalTime::class) +class FieldItem(val key: String, val value: String, val action: ActionType) { + enum class ActionType { + COPY, + HIDE + } + + enum class ItemType(val type: String, val label: String) { + USERNAME("Username", "Username"), + PASSWORD("Password", "Password"), + OTP("OTP", "OTP (expires in %ds)"), + } + + companion object { + + // Extra helper methods + fun createOtpField(totp: Totp): FieldItem { + return FieldItem( + ItemType.OTP.label.format(totp.remainingTime.inWholeSeconds), + totp.value, + ActionType.COPY, + ) + } + + fun createPasswordField(password: String): FieldItem { + return FieldItem(ItemType.PASSWORD.label, password, ActionType.HIDE) + } + + fun createUsernameField(username: String): FieldItem { + return FieldItem(ItemType.USERNAME.label, username, ActionType.COPY) + } + } +} diff --git a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt new file mode 100644 index 00000000..77497671 --- /dev/null +++ b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt @@ -0,0 +1,77 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.data.password + +import android.content.Context +import android.content.Intent +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.crypto.BasePgpActivity +import app.passwordstore.ui.main.LaunchActivity +import java.io.File + +data class PasswordItem( + val name: String, + val parent: PasswordItem? = null, + val type: Char, + val file: File, + val rootDir: File +) : Comparable { + + val fullPathToParent = file.absolutePath.replace(rootDir.absolutePath, "").replace(file.name, "") + + val longName = BasePgpActivity.getLongName(fullPathToParent, rootDir.absolutePath, toString()) + + override fun equals(other: Any?): Boolean { + return (other is PasswordItem) && (other.file == file) + } + + override fun compareTo(other: PasswordItem): Int { + return (type + name).compareTo(other.type + other.name, ignoreCase = true) + } + + override fun toString(): String { + return name.replace("\\.gpg$".toRegex(), "") + } + + override fun hashCode(): Int { + return 0 + } + + /** Creates an [Intent] to launch this [PasswordItem] through the authentication process. */ + fun createAuthEnabledIntent(context: Context): Intent { + val intent = Intent(context, LaunchActivity::class.java) + intent.putExtra("NAME", toString()) + intent.putExtra("FILE_PATH", file.absolutePath) + intent.putExtra("REPO_PATH", PasswordRepository.getRepositoryDirectory().absolutePath) + intent.action = LaunchActivity.ACTION_DECRYPT_PASS + return intent + } + + companion object { + + const val TYPE_CATEGORY = 'c' + const val TYPE_PASSWORD = 'p' + + @JvmStatic + fun newCategory(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem { + return PasswordItem(name, parent, TYPE_CATEGORY, file, rootDir) + } + + @JvmStatic + fun newCategory(name: String, file: File, rootDir: File): PasswordItem { + return PasswordItem(name, null, TYPE_CATEGORY, file, rootDir) + } + + @JvmStatic + fun newPassword(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem { + return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir) + } + + @JvmStatic + fun newPassword(name: String, file: File, rootDir: File): PasswordItem { + return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir) + } + } +} diff --git a/app/src/main/java/app/passwordstore/data/repo/PasswordRepository.kt b/app/src/main/java/app/passwordstore/data/repo/PasswordRepository.kt new file mode 100644 index 00000000..b42b7892 --- /dev/null +++ b/app/src/main/java/app/passwordstore/data/repo/PasswordRepository.kt @@ -0,0 +1,176 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.data.repo + +import androidx.core.content.edit +import app.passwordstore.Application +import app.passwordstore.data.password.PasswordItem +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.extensions.unsafeLazy +import app.passwordstore.util.settings.PasswordSortOrder +import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import java.io.File +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.lib.Repository +import org.eclipse.jgit.storage.file.FileRepositoryBuilder +import org.eclipse.jgit.transport.RefSpec +import org.eclipse.jgit.transport.RemoteConfig +import org.eclipse.jgit.transport.URIish + +object PasswordRepository { + + var repository: Repository? = null + private val settings by unsafeLazy { Application.instance.sharedPrefs } + private val filesDir + get() = Application.instance.filesDir + val isInitialized: Boolean + get() = repository != null + + fun isGitRepo(): Boolean { + return repository?.objectDatabase?.exists() ?: false + } + + /** + * Takes in a [repositoryDir] to initialize a Git repository with, and assigns it to [repository] + * as static state. + */ + private fun initializeRepository(repositoryDir: File) { + val builder = FileRepositoryBuilder() + repository = + runCatching { builder.setGitDir(repositoryDir).build() } + .getOrElse { e -> + e.printStackTrace() + null + } + } + + fun createRepository(repositoryDir: File) { + repositoryDir.delete() + Git.init().setDirectory(repositoryDir).call() + initializeRepository(repositoryDir) + } + + // TODO add multiple remotes support for pull/push + fun addRemote(name: String, url: String, replace: Boolean = false) { + val storedConfig = repository!!.config + val remotes = storedConfig.getSubsections("remote") + + if (!remotes.contains(name)) { + runCatching { + val uri = URIish(url) + val refSpec = RefSpec("+refs/head/*:refs/remotes/$name/*") + + val remoteConfig = RemoteConfig(storedConfig, name) + remoteConfig.addFetchRefSpec(refSpec) + remoteConfig.addPushRefSpec(refSpec) + remoteConfig.addURI(uri) + remoteConfig.addPushURI(uri) + + remoteConfig.update(storedConfig) + + storedConfig.save() + } + .onFailure { e -> e.printStackTrace() } + } else if (replace) { + runCatching { + val uri = URIish(url) + + val remoteConfig = RemoteConfig(storedConfig, name) + // remove the first and eventually the only uri + if (remoteConfig.urIs.size > 0) { + remoteConfig.removeURI(remoteConfig.urIs[0]) + } + if (remoteConfig.pushURIs.size > 0) { + remoteConfig.removePushURI(remoteConfig.pushURIs[0]) + } + + remoteConfig.addURI(uri) + remoteConfig.addPushURI(uri) + + remoteConfig.update(storedConfig) + + storedConfig.save() + } + .onFailure { e -> e.printStackTrace() } + } + } + + fun closeRepository() { + repository?.close() + repository = null + } + + fun getRepositoryDirectory(): File { + return File(filesDir.toString(), "/store") + } + + fun initialize(): Repository? { + val dir = getRepositoryDirectory() + // Un-initialize the repo if the dir does not exist or is absolutely empty + settings.edit { + if (!dir.exists() || !dir.isDirectory || requireNotNull(dir.listFiles()).isEmpty()) { + putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) + } else { + putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) + } + } + // Create the repository static variable in PasswordRepository + initializeRepository(dir.resolve(".git")) + + return repository + } + + /** + * Gets the .gpg files in a directory + * + * @param path the directory path + * @return the list of gpg files in that directory + */ + private fun getFilesList(path: File): ArrayList { + if (!path.exists()) return ArrayList() + val files = + (path.listFiles { file -> file.isDirectory || file.extension == "gpg" } ?: emptyArray()) + .toList() + val items = ArrayList() + items.addAll(files) + return items + } + + /** + * Gets the passwords (PasswordItem) in a directory + * + * @param path the directory path + * @return a list of password items + */ + fun getPasswords( + path: File, + rootDir: File, + sortOrder: PasswordSortOrder + ): ArrayList { + // We need to recover the passwords then parse the files + val passList = getFilesList(path).also { it.sortBy { f -> f.name } } + val passwordList = ArrayList() + val showHidden = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false) + + if (passList.size == 0) return passwordList + if (!showHidden) { + passList.filter { !it.isHidden }.toCollection(passList.apply { clear() }) + } + passList.forEach { file -> + passwordList.add( + if (file.isFile) { + PasswordItem.newPassword(file.name, file, rootDir) + } else { + PasswordItem.newCategory(file.name, file, rootDir) + } + ) + } + passwordList.sortWith(sortOrder.comparator) + return passwordList + } +} diff --git a/app/src/main/java/app/passwordstore/injection/context/ContextModule.kt b/app/src/main/java/app/passwordstore/injection/context/ContextModule.kt new file mode 100644 index 00000000..d8f28494 --- /dev/null +++ b/app/src/main/java/app/passwordstore/injection/context/ContextModule.kt @@ -0,0 +1,27 @@ +package app.passwordstore.injection.context + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +class ContextModule { + + /** + * We inject [Context.getFilesDir] to break the dependency on [Context], allowing tests to run on + * the JVM. The principle here is identical to why [app.passwordstore.util.totp.TotpFinder] + * exists. + * + * @param context [ApplicationContext] + * @return the path of app-specific files directory. + */ + @Provides + @FilesDirPath + fun providesFilesDirPath(@ApplicationContext context: Context): String { + return context.filesDir.path + } +} diff --git a/app/src/main/java/app/passwordstore/injection/context/FilesDirPath.kt b/app/src/main/java/app/passwordstore/injection/context/FilesDirPath.kt new file mode 100644 index 00000000..15da4edb --- /dev/null +++ b/app/src/main/java/app/passwordstore/injection/context/FilesDirPath.kt @@ -0,0 +1,7 @@ +package app.passwordstore.injection.context + +import android.content.Context +import javax.inject.Qualifier + +/** Qualifies a [String] representing the absolute path of [Context.getFilesDir]. */ +@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class FilesDirPath diff --git a/app/src/main/java/app/passwordstore/injection/coroutines/DispatcherModule.kt b/app/src/main/java/app/passwordstore/injection/coroutines/DispatcherModule.kt new file mode 100644 index 00000000..3539845f --- /dev/null +++ b/app/src/main/java/app/passwordstore/injection/coroutines/DispatcherModule.kt @@ -0,0 +1,19 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.injection.coroutines + +import app.passwordstore.util.coroutines.DefaultDispatcherProvider +import app.passwordstore.util.coroutines.DispatcherProvider +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface DispatcherModule { + @Binds fun DefaultDispatcherProvider.bind(): DispatcherProvider +} diff --git a/app/src/main/java/app/passwordstore/injection/crypto/CryptoHandlerModule.kt b/app/src/main/java/app/passwordstore/injection/crypto/CryptoHandlerModule.kt new file mode 100644 index 00000000..5a863d8d --- /dev/null +++ b/app/src/main/java/app/passwordstore/injection/crypto/CryptoHandlerModule.kt @@ -0,0 +1,18 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.injection.crypto + +import app.passwordstore.crypto.PGPainlessCryptoHandler +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object CryptoHandlerModule { + @Provides fun providePgpCryptoHandler() = PGPainlessCryptoHandler() +} diff --git a/app/src/main/java/app/passwordstore/injection/crypto/KeyManagerModule.kt b/app/src/main/java/app/passwordstore/injection/crypto/KeyManagerModule.kt new file mode 100644 index 00000000..69174394 --- /dev/null +++ b/app/src/main/java/app/passwordstore/injection/crypto/KeyManagerModule.kt @@ -0,0 +1,39 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.injection.crypto + +import android.content.Context +import app.passwordstore.crypto.PGPKeyManager +import app.passwordstore.util.coroutines.DispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Qualifier + +@Module +@InstallIn(SingletonComponent::class) +object KeyManagerModule { + @Provides + fun providePGPKeyManager( + @PGPKeyDir keyDir: String, + dispatcherProvider: DispatcherProvider, + ): PGPKeyManager { + return PGPKeyManager( + keyDir, + dispatcherProvider.io(), + ) + } + + @Provides + @PGPKeyDir + fun providePGPKeyDir(@ApplicationContext context: Context): String { + return context.filesDir.resolve("pgp_keys").absolutePath + } +} + +@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class PGPKeyDir diff --git a/app/src/main/java/app/passwordstore/injection/prefs/GitPreferences.kt b/app/src/main/java/app/passwordstore/injection/prefs/GitPreferences.kt new file mode 100644 index 00000000..c1bf271c --- /dev/null +++ b/app/src/main/java/app/passwordstore/injection/prefs/GitPreferences.kt @@ -0,0 +1,9 @@ +package app.passwordstore.injection.prefs + +import android.content.SharedPreferences +import javax.inject.Qualifier + +/** + * Qualifies a [SharedPreferences] instance specifically used for encrypted Git-related settings. + */ +@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class GitPreferences diff --git a/app/src/main/java/app/passwordstore/injection/prefs/PasswordGeneratorPreferences.kt b/app/src/main/java/app/passwordstore/injection/prefs/PasswordGeneratorPreferences.kt new file mode 100644 index 00000000..832c3f04 --- /dev/null +++ b/app/src/main/java/app/passwordstore/injection/prefs/PasswordGeneratorPreferences.kt @@ -0,0 +1,10 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.injection.prefs + +import javax.inject.Qualifier + +@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class PasswordGeneratorPreferences diff --git a/app/src/main/java/app/passwordstore/injection/prefs/PreferenceModule.kt b/app/src/main/java/app/passwordstore/injection/prefs/PreferenceModule.kt new file mode 100644 index 00000000..072bf031 --- /dev/null +++ b/app/src/main/java/app/passwordstore/injection/prefs/PreferenceModule.kt @@ -0,0 +1,60 @@ +package app.passwordstore.injection.prefs + +import android.content.Context +import android.content.Context.MODE_PRIVATE +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import app.passwordstore.BuildConfig +import dagger.Module +import dagger.Provides +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +class PreferenceModule { + + private fun provideBaseEncryptedPreferences( + context: Context, + fileName: String + ): SharedPreferences { + val masterKeyAlias = + MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() + return EncryptedSharedPreferences.create( + context, + fileName, + masterKeyAlias, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + @[Provides PasswordGeneratorPreferences Reusable] + fun providePwgenPreferences(@ApplicationContext context: Context): SharedPreferences { + return provideBaseEncryptedPreferences(context, "pwgen_preferences") + } + + @Provides + @SettingsPreferences + @Reusable + fun provideSettingsPreferences(@ApplicationContext context: Context): SharedPreferences { + return context.getSharedPreferences("${BuildConfig.APPLICATION_ID}_preferences", MODE_PRIVATE) + } + + @Provides + @GitPreferences + @Reusable + fun provideEncryptedPreferences(@ApplicationContext context: Context): SharedPreferences { + return provideBaseEncryptedPreferences(context, "git_operation") + } + + @Provides + @ProxyPreferences + @Reusable + fun provideProxyPreferences(@ApplicationContext context: Context): SharedPreferences { + return provideBaseEncryptedPreferences(context, "http_proxy") + } +} diff --git a/app/src/main/java/app/passwordstore/injection/prefs/ProxyPreferences.kt b/app/src/main/java/app/passwordstore/injection/prefs/ProxyPreferences.kt new file mode 100644 index 00000000..cedb9a29 --- /dev/null +++ b/app/src/main/java/app/passwordstore/injection/prefs/ProxyPreferences.kt @@ -0,0 +1,9 @@ +package app.passwordstore.injection.prefs + +import android.content.SharedPreferences +import javax.inject.Qualifier + +/** + * Qualifies a [SharedPreferences] instance specifically used for encrypted proxy-related settings. + */ +@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class ProxyPreferences diff --git a/app/src/main/java/app/passwordstore/injection/prefs/SettingsPreferences.kt b/app/src/main/java/app/passwordstore/injection/prefs/SettingsPreferences.kt new file mode 100644 index 00000000..aeeec8eb --- /dev/null +++ b/app/src/main/java/app/passwordstore/injection/prefs/SettingsPreferences.kt @@ -0,0 +1,5 @@ +package app.passwordstore.injection.prefs + +import javax.inject.Qualifier + +@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class SettingsPreferences diff --git a/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt b/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt new file mode 100644 index 00000000..78bad768 --- /dev/null +++ b/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt @@ -0,0 +1,55 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.injection.pwgen + +import android.content.Context +import app.passwordstore.passgen.diceware.DicewarePassphraseGenerator +import app.passwordstore.passgen.diceware.Die +import app.passwordstore.passgen.diceware.RandomIntGenerator +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.FragmentComponent +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.InputStream +import java.security.SecureRandom +import javax.inject.Qualifier + +@Module +@InstallIn(FragmentComponent::class) +object DicewareModule { + + @Provides + fun provideDicewareGenerator( + die: Die, + @WordlistQualifier wordList: InputStream, + ): DicewarePassphraseGenerator { + return DicewarePassphraseGenerator(die, wordList) + } + + @Provides + fun provideDie( + intGenerator: RandomIntGenerator, + ): Die { + return Die(6, intGenerator) + } + + @Provides + fun provideRandomIntGenerator(): RandomIntGenerator { + return RandomIntGenerator { range -> + SecureRandom().nextInt(range.last).coerceAtLeast(range.first) + } + } + + @[Provides WordlistQualifier] + fun provideDefaultWordList(@ApplicationContext context: Context): InputStream { + return context.resources.openRawResource( + app.passwordstore.passgen.diceware.R.raw.diceware_wordlist + ) + } +} + +@Qualifier annotation class WordlistQualifier diff --git a/app/src/main/java/app/passwordstore/injection/totp/TotpModule.kt b/app/src/main/java/app/passwordstore/injection/totp/TotpModule.kt new file mode 100644 index 00000000..ed9435ed --- /dev/null +++ b/app/src/main/java/app/passwordstore/injection/totp/TotpModule.kt @@ -0,0 +1,19 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.injection.totp + +import app.passwordstore.util.totp.TotpFinder +import app.passwordstore.util.totp.UriTotpFinder +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent + +@Module +@InstallIn(ActivityComponent::class) +interface TotpModule { + @Binds fun UriTotpFinder.bind(): TotpFinder +} diff --git a/app/src/main/java/app/passwordstore/ui/adapters/FieldItemAdapter.kt b/app/src/main/java/app/passwordstore/ui/adapters/FieldItemAdapter.kt new file mode 100644 index 00000000..590bb5c2 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/adapters/FieldItemAdapter.kt @@ -0,0 +1,92 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.adapters + +import android.text.method.PasswordTransformationMethod +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import app.passwordstore.R +import app.passwordstore.data.passfile.Totp +import app.passwordstore.data.password.FieldItem +import app.passwordstore.databinding.ItemFieldBinding +import com.google.android.material.textfield.TextInputLayout + +class FieldItemAdapter( + private var fieldItemList: List, + private val showPassword: Boolean, + private val copyTextToClipboard: (text: String?) -> Unit, +) : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldItemViewHolder { + val binding = ItemFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return FieldItemViewHolder(binding.root, binding) + } + + override fun onBindViewHolder(holder: FieldItemViewHolder, position: Int) { + holder.bind(fieldItemList[position], showPassword, copyTextToClipboard) + } + + override fun getItemCount(): Int { + return fieldItemList.size + } + + fun updateOTPCode(totp: Totp) { + var otpItemPosition = -1 + fieldItemList = + fieldItemList.mapIndexed { position, item -> + if (item.key.startsWith(FieldItem.ItemType.OTP.type, true)) { + otpItemPosition = position + return@mapIndexed FieldItem.createOtpField(totp) + } + + return@mapIndexed item + } + + notifyItemChanged(otpItemPosition) + } + + class FieldItemViewHolder(itemView: View, val binding: ItemFieldBinding) : + RecyclerView.ViewHolder(itemView) { + + fun bind(fieldItem: FieldItem, showPassword: Boolean, copyTextToClipboard: (String?) -> Unit) { + with(binding) { + itemText.hint = fieldItem.key + itemTextContainer.hint = fieldItem.key + itemText.setText(fieldItem.value) + + when (fieldItem.action) { + FieldItem.ActionType.COPY -> { + itemTextContainer.apply { + endIconDrawable = + ContextCompat.getDrawable(itemView.context, R.drawable.ic_content_copy) + endIconMode = TextInputLayout.END_ICON_CUSTOM + setEndIconOnClickListener { copyTextToClipboard(itemText.text.toString()) } + } + itemText.transformationMethod = null + } + FieldItem.ActionType.HIDE -> { + itemTextContainer.apply { + endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE + setOnClickListener { copyTextToClipboard(itemText.text.toString()) } + } + itemText.apply { + transformationMethod = + if (!showPassword) { + PasswordTransformationMethod.getInstance() + } else { + null + } + setOnClickListener { copyTextToClipboard(itemText.text.toString()) } + } + } + } + } + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt b/app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt new file mode 100644 index 00000000..cb5409c5 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt @@ -0,0 +1,94 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.adapters + +import android.text.SpannableString +import android.text.style.RelativeSizeSpan +import android.view.MotionEvent +import android.view.View +import androidx.appcompat.widget.AppCompatImageView +import androidx.appcompat.widget.AppCompatTextView +import androidx.recyclerview.selection.ItemDetailsLookup +import androidx.recyclerview.selection.Selection +import androidx.recyclerview.widget.RecyclerView +import app.passwordstore.R +import app.passwordstore.data.password.PasswordItem +import app.passwordstore.util.viewmodel.SearchableRepositoryAdapter +import app.passwordstore.util.viewmodel.stableId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +open class PasswordItemRecyclerAdapter(coroutineScope: CoroutineScope) : + SearchableRepositoryAdapter( + R.layout.password_row_layout, + ::PasswordItemViewHolder, + coroutineScope, + PasswordItemViewHolder::bind, + ) { + + fun makeSelectable(recyclerView: RecyclerView) { + makeSelectable(recyclerView, ::PasswordItemDetailsLookup) + } + + override fun onItemClicked( + listener: (holder: PasswordItemViewHolder, item: PasswordItem) -> Unit + ): PasswordItemRecyclerAdapter { + return super.onItemClicked(listener) as PasswordItemRecyclerAdapter + } + + override fun onSelectionChanged( + listener: (selection: Selection) -> Unit + ): PasswordItemRecyclerAdapter { + return super.onSelectionChanged(listener) as PasswordItemRecyclerAdapter + } + + class PasswordItemViewHolder(view: View) : RecyclerView.ViewHolder(view) { + + private val name: AppCompatTextView = itemView.findViewById(R.id.label) + private val childCount: AppCompatTextView = itemView.findViewById(R.id.child_count) + private val folderIndicator: AppCompatImageView = itemView.findViewById(R.id.folder_indicator) + lateinit var itemDetails: ItemDetailsLookup.ItemDetails + + suspend fun bind(item: PasswordItem) { + val parentPath = item.fullPathToParent.replace("(^/)|(/$)".toRegex(), "") + val source = + if (parentPath.isNotEmpty()) { + "$parentPath\n$item" + } else { + "$item" + } + val spannable = SpannableString(source) + spannable.setSpan(RelativeSizeSpan(0.7f), 0, parentPath.length, 0) + name.text = spannable + if (item.type == PasswordItem.TYPE_CATEGORY) { + folderIndicator.visibility = View.VISIBLE + val count = + withContext(Dispatchers.IO) { + item.file.listFiles { path -> path.isDirectory || path.extension == "gpg" }?.size ?: 0 + } + childCount.visibility = if (count > 0) View.VISIBLE else View.GONE + childCount.text = "$count" + } else { + childCount.visibility = View.GONE + folderIndicator.visibility = View.GONE + } + itemDetails = + object : ItemDetailsLookup.ItemDetails() { + override fun getPosition() = absoluteAdapterPosition + override fun getSelectionKey() = item.stableId + } + } + } + + class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) : + ItemDetailsLookup() { + + override fun getItemDetails(event: MotionEvent): ItemDetails? { + val view = recyclerView.findChildViewUnder(event.x, event.y) ?: return null + return (recyclerView.getChildViewHolder(view) as PasswordItemViewHolder).itemDetails + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt new file mode 100644 index 00000000..1017d3a9 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt @@ -0,0 +1,269 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.autofill + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.os.Build +import android.os.Bundle +import android.view.autofill.AutofillManager +import android.widget.Toast +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.util.autofill.AutofillPreferences +import app.passwordstore.util.autofill.AutofillResponseBuilder +import app.passwordstore.util.autofill.DirectoryStructure +import app.passwordstore.util.extensions.OPENPGP_PROVIDER +import app.passwordstore.util.extensions.asLog +import com.github.androidpasswordstore.autofillparser.AutofillAction +import com.github.androidpasswordstore.autofillparser.Credentials +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess +import com.github.michaelbull.result.runCatching +import dagger.hilt.android.AndroidEntryPoint +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import javax.inject.Inject +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import logcat.LogPriority.ERROR +import logcat.logcat +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection +import org.openintents.openpgp.IOpenPgpService2 +import org.openintents.openpgp.OpenPgpError + +@RequiresApi(26) +@AndroidEntryPoint +class AutofillDecryptActivity : AppCompatActivity() { + + companion object { + + private const val EXTRA_FILE_PATH = "app.passwordstore.autofill.oreo.EXTRA_FILE_PATH" + private const val EXTRA_SEARCH_ACTION = "app.passwordstore.autofill.oreo.EXTRA_SEARCH_ACTION" + + private var decryptFileRequestCode = 1 + + fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent { + return Intent(context, AutofillDecryptActivity::class.java).apply { + putExtras(forwardedExtras) + putExtra(EXTRA_SEARCH_ACTION, true) + putExtra(EXTRA_FILE_PATH, file.absolutePath) + } + } + + fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender { + val intent = + Intent(context, AutofillDecryptActivity::class.java).apply { + putExtra(EXTRA_SEARCH_ACTION, false) + putExtra(EXTRA_FILE_PATH, file.absolutePath) + } + return PendingIntent.getActivity( + context, + decryptFileRequestCode++, + intent, + if (Build.VERSION.SDK_INT >= 31) { + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_CANCEL_CURRENT + }, + ) + .intentSender + } + } + + @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory + + private val decryptInteractionRequiredAction = + registerForActivityResult(StartIntentSenderForResult()) { result -> + if (continueAfterUserInteraction != null) { + val data = result.data + if (result.resultCode == RESULT_OK && data != null) { + continueAfterUserInteraction?.resume(data) + } else { + continueAfterUserInteraction?.resumeWithException( + Exception("OpenPgpApi ACTION_DECRYPT_VERIFY failed to continue after user interaction") + ) + } + continueAfterUserInteraction = null + } + } + + private var continueAfterUserInteraction: Continuation? = null + private lateinit var directoryStructure: DirectoryStructure + + override fun onStart() { + super.onStart() + val filePath = + intent?.getStringExtra(EXTRA_FILE_PATH) + ?: run { + logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_FILE_PATH" } + finish() + return + } + val clientState = + intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) + ?: run { + logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } + finish() + return + } + val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!! + val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match + directoryStructure = AutofillPreferences.directoryStructure(this) + logcat { action.toString() } + lifecycleScope.launch { + val credentials = decryptCredential(File(filePath)) + if (credentials == null) { + setResult(RESULT_CANCELED) + } else { + val fillInDataset = + AutofillResponseBuilder.makeFillInDataset( + this@AutofillDecryptActivity, + credentials, + clientState, + action + ) + withContext(Dispatchers.Main) { + setResult( + RESULT_OK, + Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) } + ) + } + } + withContext(Dispatchers.Main) { finish() } + } + } + + private suspend fun executeOpenPgpApi( + data: Intent, + input: InputStream, + output: OutputStream + ): Intent { + var openPgpServiceConnection: OpenPgpServiceConnection? = null + val openPgpService = + suspendCoroutine { cont -> + openPgpServiceConnection = + OpenPgpServiceConnection( + this, + OPENPGP_PROVIDER, + object : OpenPgpServiceConnection.OnBound { + override fun onBound(service: IOpenPgpService2) { + cont.resume(service) + } + + override fun onError(e: Exception) { + cont.resumeWithException(e) + } + } + ) + .also { it.bindToService() } + } + return OpenPgpApi(this, openPgpService).executeApi(data, input, output).also { + openPgpServiceConnection?.unbindFromService() + } + } + + private suspend fun decryptCredential(file: File, resumeIntent: Intent? = null): Credentials? { + val command = resumeIntent ?: Intent().apply { action = OpenPgpApi.ACTION_DECRYPT_VERIFY } + runCatching { file.inputStream() } + .onFailure { e -> + logcat(ERROR) { e.asLog("File to decrypt not found") } + return null + } + .onSuccess { encryptedInput -> + val decryptedOutput = ByteArrayOutputStream() + runCatching { executeOpenPgpApi(command, encryptedInput, decryptedOutput) } + .onFailure { e -> + logcat(ERROR) { e.asLog("OpenPgpApi ACTION_DECRYPT_VERIFY failed") } + return null + } + .onSuccess { result -> + return when ( + val resultCode = + result.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR) + ) { + OpenPgpApi.RESULT_CODE_SUCCESS -> { + runCatching { + val entry = + withContext(Dispatchers.IO) { + @Suppress("BlockingMethodInNonBlockingContext") + passwordEntryFactory.create(decryptedOutput.toByteArray()) + } + AutofillPreferences.credentialsFromStoreEntry( + this, + file, + entry, + directoryStructure + ) + } + .getOrElse { e -> + logcat(ERROR) { e.asLog("Failed to parse password entry") } + return null + } + } + OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val pendingIntent: PendingIntent = + result.getParcelableExtra(OpenPgpApi.RESULT_INTENT)!! + runCatching { + val intentToResume = + withContext(Dispatchers.Main) { + suspendCoroutine { cont -> + continueAfterUserInteraction = cont + decryptInteractionRequiredAction.launch( + IntentSenderRequest.Builder(pendingIntent.intentSender).build() + ) + } + } + decryptCredential(file, intentToResume) + } + .getOrElse { e -> + logcat(ERROR) { + e.asLog("OpenPgpApi ACTION_DECRYPT_VERIFY failed with user interaction") + } + return null + } + } + OpenPgpApi.RESULT_CODE_ERROR -> { + val error = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR) + if (error != null) { + withContext(Dispatchers.Main) { + Toast.makeText( + applicationContext, + "Error from OpenKeyChain: ${error.message}", + Toast.LENGTH_LONG + ) + .show() + } + logcat(ERROR) { + "OpenPgpApi ACTION_DECRYPT_VERIFY failed (${error.errorId}): ${error.message}" + } + } + null + } + else -> { + logcat(ERROR) { "Unrecognized OpenPgpApi result: $resultCode" } + null + } + } + } + } + return null + } +} diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivityV2.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivityV2.kt new file mode 100644 index 00000000..35d6ca44 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivityV2.kt @@ -0,0 +1,180 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.autofill + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.os.Build +import android.os.Bundle +import android.view.autofill.AutofillManager +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import app.passwordstore.data.crypto.CryptoRepository +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.ui.crypto.PasswordDialog +import app.passwordstore.util.autofill.AutofillPreferences +import app.passwordstore.util.autofill.AutofillResponseBuilder +import app.passwordstore.util.autofill.DirectoryStructure +import app.passwordstore.util.extensions.asLog +import com.github.androidpasswordstore.autofillparser.AutofillAction +import com.github.androidpasswordstore.autofillparser.Credentials +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess +import com.github.michaelbull.result.runCatching +import dagger.hilt.android.AndroidEntryPoint +import java.io.ByteArrayOutputStream +import java.io.File +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import logcat.LogPriority.ERROR +import logcat.logcat + +@RequiresApi(26) +@AndroidEntryPoint +class AutofillDecryptActivityV2 : AppCompatActivity() { + + companion object { + + private const val EXTRA_FILE_PATH = "app.passwordstore.autofill.oreo.EXTRA_FILE_PATH" + private const val EXTRA_SEARCH_ACTION = "app.passwordstore.autofill.oreo.EXTRA_SEARCH_ACTION" + + private var decryptFileRequestCode = 1 + + fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent { + return Intent(context, AutofillDecryptActivityV2::class.java).apply { + putExtras(forwardedExtras) + putExtra(EXTRA_SEARCH_ACTION, true) + putExtra(EXTRA_FILE_PATH, file.absolutePath) + } + } + + fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender { + val intent = + Intent(context, AutofillDecryptActivityV2::class.java).apply { + putExtra(EXTRA_SEARCH_ACTION, false) + putExtra(EXTRA_FILE_PATH, file.absolutePath) + } + return PendingIntent.getActivity( + context, + decryptFileRequestCode++, + intent, + if (Build.VERSION.SDK_INT >= 31) { + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_CANCEL_CURRENT + }, + ) + .intentSender + } + } + + @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory + @Inject lateinit var repository: CryptoRepository + + private lateinit var directoryStructure: DirectoryStructure + + override fun onStart() { + super.onStart() + val filePath = + intent?.getStringExtra(EXTRA_FILE_PATH) + ?: run { + logcat(ERROR) { "AutofillDecryptActivityV2 started without EXTRA_FILE_PATH" } + finish() + return + } + val clientState = + intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) + ?: run { + logcat(ERROR) { "AutofillDecryptActivityV2 started without EXTRA_CLIENT_STATE" } + finish() + return + } + val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!! + val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match + directoryStructure = AutofillPreferences.directoryStructure(this) + logcat { action.toString() } + val dialog = PasswordDialog() + lifecycleScope.launch { + withContext(Dispatchers.Main) { + dialog.password.collectLatest { value -> + if (value != null) { + decrypt(File(filePath), clientState, action, value) + } + } + } + } + dialog.show(supportFragmentManager, "PASSWORD_DIALOG") + } + + private suspend fun decrypt( + filePath: File, + clientState: Bundle, + action: AutofillAction, + password: String, + ) { + val credentials = decryptCredential(filePath, password) + if (credentials == null) { + setResult(RESULT_CANCELED) + } else { + val fillInDataset = + AutofillResponseBuilder.makeFillInDataset( + this@AutofillDecryptActivityV2, + credentials, + clientState, + action + ) + withContext(Dispatchers.Main) { + setResult( + RESULT_OK, + Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) } + ) + } + } + withContext(Dispatchers.Main) { finish() } + } + + private suspend fun decryptCredential(file: File, password: String): Credentials? { + runCatching { file.readBytes().inputStream() } + .onFailure { e -> + logcat(ERROR) { e.asLog("File to decrypt not found") } + return null + } + .onSuccess { encryptedInput -> + runCatching { + withContext(Dispatchers.IO) { + val outputStream = ByteArrayOutputStream() + repository.decrypt( + password, + encryptedInput, + outputStream, + ) + outputStream + } + } + .onFailure { e -> + logcat(ERROR) { e.asLog("Decryption failed") } + return null + } + .onSuccess { result -> + return runCatching { + val entry = passwordEntryFactory.create(result.toByteArray()) + AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure) + } + .getOrElse { e -> + logcat(ERROR) { e.asLog("Failed to parse password entry") } + return null + } + } + } + return null + } +} diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt new file mode 100644 index 00000000..869904c1 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt @@ -0,0 +1,241 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.autofill + +import android.annotation.TargetApi +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.os.Build +import android.os.Bundle +import android.view.View +import android.view.autofill.AutofillManager +import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.text.bold +import androidx.core.text.buildSpannedString +import androidx.core.text.underline +import androidx.core.widget.addTextChangedListener +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import app.passwordstore.R +import app.passwordstore.data.password.PasswordItem +import app.passwordstore.databinding.ActivityOreoAutofillFilterBinding +import app.passwordstore.util.autofill.AutofillMatcher +import app.passwordstore.util.autofill.AutofillPreferences +import app.passwordstore.util.autofill.DirectoryStructure +import app.passwordstore.util.extensions.viewBinding +import app.passwordstore.util.features.Feature +import app.passwordstore.util.features.Features +import app.passwordstore.util.viewmodel.FilterMode +import app.passwordstore.util.viewmodel.ListMode +import app.passwordstore.util.viewmodel.SearchMode +import app.passwordstore.util.viewmodel.SearchableRepositoryAdapter +import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel +import com.github.androidpasswordstore.autofillparser.FormOrigin +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import logcat.LogPriority.ERROR +import logcat.logcat + +@TargetApi(26) +@AndroidEntryPoint +class AutofillFilterView : AppCompatActivity() { + + companion object { + + private const val HEIGHT_PERCENTAGE = 0.9 + private const val WIDTH_PERCENTAGE = 0.75 + + private const val EXTRA_FORM_ORIGIN_WEB = + "app.passwordstore.autofill.oreo.ui.EXTRA_FORM_ORIGIN_WEB" + private const val EXTRA_FORM_ORIGIN_APP = + "app.passwordstore.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP" + private var matchAndDecryptFileRequestCode = 1 + + fun makeMatchAndDecryptFileIntentSender( + context: Context, + formOrigin: FormOrigin + ): IntentSender { + val intent = + Intent(context, AutofillFilterView::class.java).apply { + when (formOrigin) { + is FormOrigin.Web -> putExtra(EXTRA_FORM_ORIGIN_WEB, formOrigin.identifier) + is FormOrigin.App -> putExtra(EXTRA_FORM_ORIGIN_APP, formOrigin.identifier) + } + } + return PendingIntent.getActivity( + context, + matchAndDecryptFileRequestCode++, + intent, + if (Build.VERSION.SDK_INT >= 31) { + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_CANCEL_CURRENT + }, + ) + .intentSender + } + } + + @Inject lateinit var features: Features + private lateinit var formOrigin: FormOrigin + private lateinit var directoryStructure: DirectoryStructure + private val binding by viewBinding(ActivityOreoAutofillFilterBinding::inflate) + + private val model: SearchableRepositoryViewModel by viewModels { + ViewModelProvider.AndroidViewModelFactory(application) + } + + private val decryptAction = + registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + setResult(RESULT_OK, result.data) + } + finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + setFinishOnTouchOutside(true) + + val params = window.attributes + params.height = (HEIGHT_PERCENTAGE * resources.displayMetrics.heightPixels).toInt() + params.width = (WIDTH_PERCENTAGE * resources.displayMetrics.widthPixels).toInt() + window.attributes = params + + if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true) { + logcat(ERROR) { "AutofillFilterActivity started without EXTRA_CLIENT_STATE" } + finish() + return + } + formOrigin = + when { + intent?.hasExtra(EXTRA_FORM_ORIGIN_WEB) == true -> { + FormOrigin.Web(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_WEB)!!) + } + intent?.hasExtra(EXTRA_FORM_ORIGIN_APP) == true -> { + FormOrigin.App(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_APP)!!) + } + else -> { + logcat(ERROR) { + "AutofillFilterActivity started without EXTRA_FORM_ORIGIN_WEB or EXTRA_FORM_ORIGIN_APP" + } + finish() + return + } + } + directoryStructure = AutofillPreferences.directoryStructure(this) + + supportActionBar?.hide() + bindUI() + updateSearch() + setResult(RESULT_CANCELED) + } + + private fun bindUI() { + with(binding) { + rvPassword.apply { + adapter = + SearchableRepositoryAdapter( + R.layout.oreo_autofill_filter_row, + ::PasswordViewHolder, + lifecycleScope, + ) { item -> + val file = item.file.relativeTo(item.rootDir) + val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file) + val identifier = directoryStructure.getIdentifierFor(file) + val accountPart = directoryStructure.getAccountPartFor(file) + check(identifier != null || accountPart != null) { + "At least one of identifier and accountPart should always be non-null" + } + title.text = + if (identifier != null) { + buildSpannedString { + if (pathToIdentifier != null) append("$pathToIdentifier/") + bold { underline { append(identifier) } } + } + } else { + accountPart + } + subtitle.apply { + if (identifier != null && accountPart != null) { + text = accountPart + visibility = View.VISIBLE + } else { + visibility = View.GONE + } + } + } + .onItemClicked { _, item -> decryptAndFill(item) } + layoutManager = LinearLayoutManager(context) + } + search.apply { + val initialSearch = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false) + setText(initialSearch, TextView.BufferType.EDITABLE) + addTextChangedListener { updateSearch() } + } + origin.text = buildSpannedString { + append(getString(R.string.oreo_autofill_select_and_fill_into)) + append("\n") + bold { append(formOrigin.getPrettyIdentifier(applicationContext, untrusted = true)) } + } + strictDomainSearch.apply { + visibility = if (formOrigin is FormOrigin.Web) View.VISIBLE else View.GONE + isChecked = formOrigin is FormOrigin.Web + setOnCheckedChangeListener { _, _ -> updateSearch() } + } + shouldMatch.text = + getString( + R.string.oreo_autofill_match_with, + formOrigin.getPrettyIdentifier(applicationContext) + ) + model.searchResult.observe(this@AutofillFilterView) { result -> + val list = result.passwordItems + (rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) { + rvPassword.scrollToPosition(0) + } + // Switch RecyclerView out for a "no results" message if the new list is empty and + // the message is not yet shown (and vice versa). + if ( + (list.isEmpty() && rvPasswordSwitcher.nextView.id == rvPasswordEmpty.id) || + (list.isNotEmpty() && rvPasswordSwitcher.nextView.id == rvPassword.id) + ) { + rvPasswordSwitcher.showNext() + } + } + } + } + + private fun updateSearch() { + model.search( + binding.search.text.toString().trim(), + filterMode = + if (binding.strictDomainSearch.isChecked) FilterMode.StrictDomain else FilterMode.Fuzzy, + searchMode = SearchMode.RecursivelyInSubdirectories, + listMode = ListMode.FilesOnly + ) + } + + private fun decryptAndFill(item: PasswordItem) { + if (binding.shouldClear.isChecked) + AutofillMatcher.clearMatchesFor(applicationContext, formOrigin) + if (binding.shouldMatch.isChecked) + AutofillMatcher.addMatchFor(applicationContext, formOrigin, item.file) + // intent?.extras? is checked to be non-null in onCreate + decryptAction.launch( + if (features.isEnabled(Feature.EnablePGPainlessBackend)) { + AutofillDecryptActivityV2.makeDecryptFileIntent(item.file, intent!!.extras!!, this) + } else { + AutofillDecryptActivity.makeDecryptFileIntent(item.file, intent!!.extras!!, this) + } + ) + } +} diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt new file mode 100644 index 00000000..dd54b8eb --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt @@ -0,0 +1,135 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.autofill + +import android.annotation.TargetApi +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.service.autofill.FillResponse +import android.text.format.DateUtils +import android.view.View +import android.view.autofill.AutofillManager +import androidx.appcompat.app.AppCompatActivity +import app.passwordstore.R +import app.passwordstore.databinding.ActivityOreoAutofillPublisherChangedBinding +import app.passwordstore.util.autofill.AutofillMatcher +import app.passwordstore.util.autofill.AutofillPublisherChangedException +import app.passwordstore.util.extensions.asLog +import app.passwordstore.util.extensions.viewBinding +import com.github.androidpasswordstore.autofillparser.FormOrigin +import com.github.androidpasswordstore.autofillparser.computeCertificatesHash +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import logcat.LogPriority.ERROR +import logcat.logcat + +@TargetApi(26) +class AutofillPublisherChangedActivity : AppCompatActivity() { + + companion object { + + private const val EXTRA_APP_PACKAGE = "app.passwordstore.autofill.oreo.ui.EXTRA_APP_PACKAGE" + private const val EXTRA_FILL_RESPONSE_AFTER_RESET = + "app.passwordstore.autofill.oreo.ui.EXTRA_FILL_RESPONSE_AFTER_RESET" + private var publisherChangedRequestCode = 1 + + fun makePublisherChangedIntentSender( + context: Context, + publisherChangedException: AutofillPublisherChangedException, + fillResponseAfterReset: FillResponse?, + ): IntentSender { + val intent = + Intent(context, AutofillPublisherChangedActivity::class.java).apply { + putExtra(EXTRA_APP_PACKAGE, publisherChangedException.formOrigin.identifier) + putExtra(EXTRA_FILL_RESPONSE_AFTER_RESET, fillResponseAfterReset) + } + return PendingIntent.getActivity( + context, + publisherChangedRequestCode++, + intent, + if (Build.VERSION.SDK_INT >= 31) { + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_CANCEL_CURRENT + }, + ) + .intentSender + } + } + + private lateinit var appPackage: String + private val binding by viewBinding(ActivityOreoAutofillPublisherChangedBinding::inflate) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + setFinishOnTouchOutside(true) + + appPackage = + intent.getStringExtra(EXTRA_APP_PACKAGE) + ?: run { + logcat(ERROR) { "AutofillPublisherChangedActivity started without EXTRA_PACKAGE_NAME" } + finish() + return + } + supportActionBar?.hide() + showPackageInfo() + with(binding) { + okButton.setOnClickListener { finish() } + advancedButton.setOnClickListener { + advancedButton.visibility = View.GONE + warningAppAdvancedInfo.visibility = View.VISIBLE + resetButton.visibility = View.VISIBLE + } + resetButton.setOnClickListener { + AutofillMatcher.clearMatchesFor( + this@AutofillPublisherChangedActivity, + FormOrigin.App(appPackage) + ) + val fillResponse = intent.getParcelableExtra(EXTRA_FILL_RESPONSE_AFTER_RESET) + setResult( + RESULT_OK, + Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse) } + ) + finish() + } + } + } + + private fun showPackageInfo() { + runCatching { + with(binding) { + val packageInfo = packageManager.getPackageInfo(appPackage, PackageManager.GET_META_DATA) + val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime) + warningAppInstallDate.text = + getString(R.string.oreo_autofill_warning_publisher_install_time, installTime) + val appInfo = packageManager.getApplicationInfo(appPackage, PackageManager.GET_META_DATA) + warningAppName.text = + getString( + R.string.oreo_autofill_warning_publisher_app_name, + packageManager.getApplicationLabel(appInfo) + ) + + val currentHash = + computeCertificatesHash(this@AutofillPublisherChangedActivity, appPackage) + warningAppAdvancedInfo.text = + getString( + R.string.oreo_autofill_warning_publisher_advanced_info_template, + appPackage, + currentHash + ) + } + } + .onFailure { e -> + logcat(ERROR) { e.asLog("Failed to retrieve package info for $appPackage") } + finish() + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt new file mode 100644 index 00000000..d5cb7e1c --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt @@ -0,0 +1,173 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.autofill + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.os.Bundle +import android.view.autofill.AutofillManager +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import androidx.core.os.bundleOf +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.crypto.PasswordCreationActivity +import app.passwordstore.ui.crypto.PasswordCreationActivityV2 +import app.passwordstore.util.autofill.AutofillMatcher +import app.passwordstore.util.autofill.AutofillPreferences +import app.passwordstore.util.autofill.AutofillResponseBuilder +import app.passwordstore.util.extensions.unsafeLazy +import app.passwordstore.util.features.Feature +import app.passwordstore.util.features.Features +import com.github.androidpasswordstore.autofillparser.AutofillAction +import com.github.androidpasswordstore.autofillparser.Credentials +import com.github.androidpasswordstore.autofillparser.FormOrigin +import dagger.hilt.android.AndroidEntryPoint +import java.io.File +import javax.inject.Inject +import logcat.LogPriority.ERROR +import logcat.logcat + +@RequiresApi(26) +@AndroidEntryPoint +class AutofillSaveActivity : AppCompatActivity() { + + companion object { + + private const val EXTRA_FOLDER_NAME = "app.passwordstore.autofill.oreo.ui.EXTRA_FOLDER_NAME" + private const val EXTRA_PASSWORD = "app.passwordstore.autofill.oreo.ui.EXTRA_PASSWORD" + private const val EXTRA_NAME = "app.passwordstore.autofill.oreo.ui.EXTRA_NAME" + private const val EXTRA_SHOULD_MATCH_APP = + "app.passwordstore.autofill.oreo.ui.EXTRA_SHOULD_MATCH_APP" + private const val EXTRA_SHOULD_MATCH_WEB = + "app.passwordstore.autofill.oreo.ui.EXTRA_SHOULD_MATCH_WEB" + private const val EXTRA_GENERATE_PASSWORD = + "app.passwordstore.autofill.oreo.ui.EXTRA_GENERATE_PASSWORD" + + private var saveRequestCode = 1 + + fun makeSaveIntentSender( + context: Context, + credentials: Credentials?, + formOrigin: FormOrigin + ): IntentSender { + val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false) + // Prevent directory traversals + val sanitizedIdentifier = + identifier.replace('\\', '_').replace('/', '_').trimStart('.').takeUnless { it.isBlank() } + ?: formOrigin.identifier + val directoryStructure = AutofillPreferences.directoryStructure(context) + val folderName = + directoryStructure.getSaveFolderName( + sanitizedIdentifier = sanitizedIdentifier, + username = credentials?.username + ) + val fileName = + directoryStructure.getSaveFileName( + username = credentials?.username, + identifier = identifier + ) + val intent = + Intent(context, AutofillSaveActivity::class.java).apply { + putExtras( + bundleOf( + EXTRA_FOLDER_NAME to folderName, + EXTRA_NAME to fileName, + EXTRA_PASSWORD to credentials?.password, + EXTRA_SHOULD_MATCH_APP to + formOrigin.identifier.takeIf { formOrigin is FormOrigin.App }, + EXTRA_SHOULD_MATCH_WEB to + formOrigin.identifier.takeIf { formOrigin is FormOrigin.Web }, + EXTRA_GENERATE_PASSWORD to (credentials == null) + ) + ) + } + return PendingIntent.getActivity( + context, + saveRequestCode++, + intent, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + .intentSender + } + } + + private val formOrigin by unsafeLazy { + val shouldMatchApp: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_APP) + val shouldMatchWeb: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_WEB) + if (shouldMatchApp != null && shouldMatchWeb == null) { + FormOrigin.App(shouldMatchApp) + } else if (shouldMatchApp == null && shouldMatchWeb != null) { + FormOrigin.Web(shouldMatchWeb) + } else { + null + } + } + + @Inject lateinit var features: Features + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val repo = PasswordRepository.getRepositoryDirectory() + val creationActivity = + if (features.isEnabled(Feature.EnablePGPainlessBackend)) + PasswordCreationActivityV2::class.java + else PasswordCreationActivity::class.java + val saveIntent = + Intent(this, creationActivity).apply { + putExtras( + bundleOf( + "REPO_PATH" to repo.absolutePath, + "FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)!!).absolutePath, + PasswordCreationActivity.EXTRA_FILE_NAME to intent.getStringExtra(EXTRA_NAME), + PasswordCreationActivity.EXTRA_PASSWORD to intent.getStringExtra(EXTRA_PASSWORD), + PasswordCreationActivity.EXTRA_GENERATE_PASSWORD to + intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) + ) + ) + } + registerForActivityResult(StartActivityForResult()) { result -> + val data = result.data + if (result.resultCode == RESULT_OK && data != null) { + val createdPath = data.getStringExtra("CREATED_FILE")!! + formOrigin?.let { AutofillMatcher.addMatchFor(this, it, File(createdPath)) } + val password = data.getStringExtra("PASSWORD") + val resultIntent = + if (password != null) { + // Password was generated and should be filled into a form. + val username = data.getStringExtra("USERNAME") + val clientState = + intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) + ?: run { + logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } + finish() + return@registerForActivityResult + } + val credentials = Credentials(username, password, null) + val fillInDataset = + AutofillResponseBuilder.makeFillInDataset( + this, + credentials, + clientState, + AutofillAction.Generate + ) + Intent().apply { + putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) + } + } else { + // Password was extracted from a form, there is nothing to fill. + Intent() + } + setResult(RESULT_OK, resultIntent) + } else { + setResult(RESULT_CANCELED) + } + finish() + } + .launch(saveIntent) + } +} diff --git a/app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt b/app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt new file mode 100644 index 00000000..fdfa0eb0 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt @@ -0,0 +1,16 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.autofill + +import android.view.View +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import app.passwordstore.R + +class PasswordViewHolder(view: View) : RecyclerView.ViewHolder(view) { + + val title: TextView = itemView.findViewById(R.id.title) + val subtitle: TextView = itemView.findViewById(R.id.subtitle) +} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/BasePgpActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/BasePgpActivity.kt new file mode 100644 index 00000000..8e97b876 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/crypto/BasePgpActivity.kt @@ -0,0 +1,290 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.crypto + +import android.app.PendingIntent +import android.content.ClipData +import android.content.Intent +import android.content.IntentSender +import android.content.SharedPreferences +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.view.WindowManager +import androidx.annotation.CallSuper +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +import app.passwordstore.R +import app.passwordstore.injection.prefs.SettingsPreferences +import app.passwordstore.util.extensions.OPENPGP_PROVIDER +import app.passwordstore.util.extensions.asLog +import app.passwordstore.util.extensions.clipboard +import app.passwordstore.util.extensions.getString +import app.passwordstore.util.extensions.snackbar +import app.passwordstore.util.extensions.unsafeLazy +import app.passwordstore.util.features.Feature +import app.passwordstore.util.features.Features +import app.passwordstore.util.services.ClipboardService +import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.getOr +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint +import java.io.File +import javax.inject.Inject +import logcat.LogPriority.ERROR +import logcat.LogPriority.INFO +import logcat.logcat +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection +import org.openintents.openpgp.IOpenPgpService2 +import org.openintents.openpgp.OpenPgpError + +@Suppress("Registered") +@AndroidEntryPoint +open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { + + /** Full path to the repository */ + val repoPath by unsafeLazy { intent.getStringExtra("REPO_PATH")!! } + + /** Full path to the password file being worked on */ + val fullPath by unsafeLazy { intent.getStringExtra("FILE_PATH")!! } + + /** + * Name of the password file + * + * Converts personal/auth.foo.org/john_doe@example.org.gpg to john_doe.example.org + */ + val name: String by unsafeLazy { File(fullPath).nameWithoutExtension } + + /** [SharedPreferences] instance used by subclasses to persist settings */ + @SettingsPreferences @Inject lateinit var settings: SharedPreferences + + @Inject lateinit var features: Features + + /** + * Handle to the [OpenPgpApi] instance that is used by subclasses to interface with OpenKeychain. + */ + private var serviceConnection: OpenPgpServiceConnection? = null + var api: OpenPgpApi? = null + + /** + * A [OpenPgpServiceConnection.OnBound] instance for the last listener that we wish to bind with + * in case the previous attempt was cancelled due to missing [OPENPGP_PROVIDER] package. + */ + private var previousListener: OpenPgpServiceConnection.OnBound? = null + + /** + * [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots or + * recent apps screen. + */ + @CallSuper + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) + } + + /** + * [onDestroy] handles unbinding from the OpenPgp service linked with [serviceConnection]. This is + * annotated with [CallSuper] because it's critical to unbind the service to ensure we're not + * leaking things. + */ + @CallSuper + override fun onDestroy() { + super.onDestroy() + serviceConnection?.unbindFromService() + previousListener = null + } + + /** + * [onResume] controls the flow for resumption of a PGP operation that was previously interrupted + * by the [OPENPGP_PROVIDER] package being missing. + */ + override fun onResume() { + super.onResume() + previousListener?.let { bindToOpenKeychain(it) } + } + + /** + * Sets up [api] once the service is bound. Downstream consumers must call super this to + * initialize [api] + */ + @CallSuper + override fun onBound(service: IOpenPgpService2) { + api = OpenPgpApi(this, service) + } + + /** + * Mandatory error handling from [OpenPgpServiceConnection.OnBound]. All subclasses must handle + * their own errors, and hence this class simply logs and rethrows. Subclasses Must NOT call + * super. + */ + override fun onError(e: Exception) { + logcat(ERROR) { e.asLog("Callers must handle their own exceptions") } + throw e + } + + /** Method for subclasses to initiate binding with [OpenPgpServiceConnection]. */ + fun bindToOpenKeychain(onBoundListener: OpenPgpServiceConnection.OnBound) { + if (features.isEnabled(Feature.EnablePGPainlessBackend)) return + val installed = + runCatching { + packageManager.getPackageInfo(OPENPGP_PROVIDER, 0) + true + } + .getOr(false) + if (!installed) { + previousListener = onBoundListener + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.openkeychain_not_installed_title)) + .setMessage(getString(R.string.openkeychain_not_installed_message)) + .setPositiveButton(getString(R.string.openkeychain_not_installed_google_play)) { _, _ -> + runCatching { + val intent = + Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(getString(R.string.play_deeplink_template, OPENPGP_PROVIDER)) + setPackage("com.android.vending") + } + startActivity(intent) + } + } + .setNeutralButton(getString(R.string.openkeychain_not_installed_fdroid)) { _, _ -> + runCatching { + val intent = + Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(getString(R.string.fdroid_deeplink_template, OPENPGP_PROVIDER)) + } + startActivity(intent) + } + } + .setOnCancelListener { finish() } + .show() + return + } else { + previousListener = null + serviceConnection = + OpenPgpServiceConnection(this, OPENPGP_PROVIDER, onBoundListener).also { + it.bindToService() + } + } + } + + /** + * Handle the case where OpenKeychain returns that it needs to interact with the user + * + * @param result The intent returned by OpenKeychain + */ + fun getUserInteractionRequestIntent(result: Intent): IntentSender { + logcat(INFO) { "RESULT_CODE_USER_INTERACTION_REQUIRED" } + return result.getParcelableExtra(OpenPgpApi.RESULT_INTENT)!!.intentSender + } + + /** + * Base handling of OpenKeychain errors based on the error contained in [result]. Subclasses can + * use this when they want to default to sane error handling. + */ + fun handleError(result: Intent) { + val error: OpenPgpError? = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR) + if (error != null) { + when (error.errorId) { + OpenPgpError.NO_OR_WRONG_PASSPHRASE -> { + snackbar(message = getString(R.string.openpgp_error_wrong_passphrase)) + } + OpenPgpError.NO_USER_IDS -> { + snackbar(message = getString(R.string.openpgp_error_no_user_ids)) + } + else -> { + snackbar(message = getString(R.string.openpgp_error_unknown, error.message)) + logcat(ERROR) { "onError getErrorId: ${error.errorId}" } + logcat(ERROR) { "onError getMessage: ${error.message}" } + } + } + } + } + + /** + * Copies provided [text] to the clipboard. Shows a [Snackbar] which can be disabled by passing + * [showSnackbar] as false. + */ + fun copyTextToClipboard( + text: String?, + showSnackbar: Boolean = true, + @StringRes snackbarTextRes: Int = R.string.clipboard_copied_text + ) { + val clipboard = clipboard ?: return + val clip = ClipData.newPlainText("pgp_handler_result_pm", text) + clipboard.setPrimaryClip(clip) + if (showSnackbar) { + snackbar(message = resources.getString(snackbarTextRes)) + } + } + + /** + * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] to hide + * the default [Snackbar] and starts off an instance of [ClipboardService] to provide a way of + * clearing the clipboard. + */ + fun copyPasswordToClipboard(password: String?) { + copyTextToClipboard(password, showSnackbar = false) + + val clearAfter = settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() ?: 45 + + if (clearAfter != 0) { + val service = + Intent(this, ClipboardService::class.java).apply { + action = ClipboardService.ACTION_START + putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter) + } + if (Build.VERSION.SDK_INT >= 26) { + startForegroundService(service) + } else { + startService(service) + } + snackbar(message = resources.getString(R.string.clipboard_password_toast_text, clearAfter)) + } else { + snackbar(message = resources.getString(R.string.clipboard_password_no_clear_toast_text)) + } + } + + companion object { + + private const val TAG = "APS/BasePgpActivity" + const val EXTRA_FILE_PATH = "FILE_PATH" + const val EXTRA_REPO_PATH = "REPO_PATH" + + /** Gets the relative path to the repository */ + fun getRelativePath(fullPath: String, repositoryPath: String): String = + fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") + + /** Gets the Parent path, relative to the repository */ + fun getParentPath(fullPath: String, repositoryPath: String): String { + val relativePath = getRelativePath(fullPath, repositoryPath) + val index = relativePath.lastIndexOf("/") + return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/".replace( + "/+".toRegex(), + "/" + ) + } + + /** /path/to/store/social/facebook.gpg -> social/facebook */ + @JvmStatic + fun getLongName(fullPath: String, repositoryPath: String, basename: String): String { + var relativePath = getRelativePath(fullPath, repositoryPath) + return if (relativePath.isNotEmpty() && relativePath != "/") { + // remove preceding '/' + relativePath = relativePath.substring(1) + if (relativePath.endsWith('/')) { + relativePath + basename + } else { + "$relativePath/$basename" + } + } else { + basename + } + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt new file mode 100644 index 00000000..f3fac1f2 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt @@ -0,0 +1,227 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.crypto + +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.data.password.FieldItem +import app.passwordstore.databinding.DecryptLayoutBinding +import app.passwordstore.ui.adapters.FieldItemAdapter +import app.passwordstore.util.extensions.unsafeLazy +import app.passwordstore.util.extensions.viewBinding +import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import dagger.hilt.android.AndroidEntryPoint +import java.io.ByteArrayOutputStream +import java.io.File +import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.ExperimentalTime +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import logcat.LogPriority.ERROR +import logcat.asLog +import logcat.logcat +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection +import org.openintents.openpgp.IOpenPgpService2 + +@AndroidEntryPoint +class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { + + private val binding by viewBinding(DecryptLayoutBinding::inflate) + @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory + + private val relativeParentPath by unsafeLazy { getParentPath(fullPath, repoPath) } + private var passwordEntry: PasswordEntry? = null + + private val userInteractionRequiredResult = + registerForActivityResult(StartIntentSenderForResult()) { result -> + if (result.data == null) { + setResult(RESULT_CANCELED, null) + finish() + return@registerForActivityResult + } + + when (result.resultCode) { + RESULT_OK -> decryptAndVerify(result.data) + RESULT_CANCELED -> { + setResult(RESULT_CANCELED, result.data) + finish() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + bindToOpenKeychain(this) + title = name + with(binding) { + setContentView(root) + passwordCategory.text = relativeParentPath + passwordFile.text = name + passwordFile.setOnLongClickListener { + copyTextToClipboard(name) + true + } + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.pgp_handler, menu) + passwordEntry?.let { entry -> + menu.findItem(R.id.edit_password).isVisible = true + if (!entry.password.isNullOrBlank()) { + menu.findItem(R.id.share_password_as_plaintext).isVisible = true + menu.findItem(R.id.copy_password).isVisible = true + } + } + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> onBackPressed() + R.id.edit_password -> editPassword() + R.id.share_password_as_plaintext -> shareAsPlaintext() + R.id.copy_password -> copyPasswordToClipboard(passwordEntry?.password) + else -> return super.onOptionsItemSelected(item) + } + return true + } + + override fun onBound(service: IOpenPgpService2) { + super.onBound(service) + decryptAndVerify() + } + + override fun onError(e: Exception) { + logcat(ERROR) { e.asLog() } + } + + /** + * Automatically finishes the activity 60 seconds after decryption succeeded to prevent + * information leaks from stale activities. + */ + @OptIn(ExperimentalTime::class) + private fun startAutoDismissTimer() { + lifecycleScope.launch { + delay(Duration.seconds(60)) + finish() + } + } + + /** + * Edit the current password and hide all the fields populated by encrypted data so that when the + * result triggers they can be repopulated with new data. + */ + private fun editPassword() { + val intent = Intent(this, PasswordCreationActivity::class.java) + intent.putExtra("FILE_PATH", relativeParentPath) + intent.putExtra("REPO_PATH", repoPath) + intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name) + intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password) + intent.putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, passwordEntry?.extraContentString) + intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true) + startActivity(intent) + finish() + } + + private fun shareAsPlaintext() { + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, passwordEntry?.password) + type = "text/plain" + } + // Always show a picker to give the user a chance to cancel + startActivity( + Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to)) + ) + } + + @OptIn(ExperimentalTime::class) + private fun decryptAndVerify(receivedIntent: Intent? = null) { + if (api == null) { + bindToOpenKeychain(this) + return + } + val data = receivedIntent ?: Intent() + data.action = OpenPgpApi.ACTION_DECRYPT_VERIFY + + val inputStream = File(fullPath).inputStream() + val outputStream = ByteArrayOutputStream() + + lifecycleScope.launch(Dispatchers.Main) { + val result = + withContext(Dispatchers.IO) { + checkNotNull(api).executeApi(data, inputStream, outputStream) + } + when (result.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { + OpenPgpApi.RESULT_CODE_SUCCESS -> { + startAutoDismissTimer() + runCatching { + val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true) + val entry = passwordEntryFactory.create(outputStream.toByteArray()) + + if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) { + copyPasswordToClipboard(entry.password) + } + + passwordEntry = entry + invalidateOptionsMenu() + + val items = arrayListOf() + if (!entry.password.isNullOrBlank()) { + items.add(FieldItem.createPasswordField(entry.password!!)) + } + + if (entry.hasTotp()) { + items.add(FieldItem.createOtpField(entry.totp.first())) + } + + if (!entry.username.isNullOrBlank()) { + items.add(FieldItem.createUsernameField(entry.username!!)) + } + + entry.extraContent.forEach { (key, value) -> + items.add(FieldItem(key, value, FieldItem.ActionType.COPY)) + } + + val adapter = + FieldItemAdapter(items, showPassword) { text -> copyTextToClipboard(text) } + binding.recyclerView.adapter = adapter + binding.recyclerView.itemAnimator = null + + if (entry.hasTotp()) { + entry.totp.onEach(adapter::updateOTPCode).launchIn(lifecycleScope) + } + } + .onFailure { e -> logcat(ERROR) { e.asLog() } } + } + OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val sender = getUserInteractionRequestIntent(result) + userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build()) + } + OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) + } + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivityV2.kt b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivityV2.kt new file mode 100644 index 00000000..fc5cb90b --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivityV2.kt @@ -0,0 +1,209 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.crypto + +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.data.crypto.CryptoRepository +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.data.password.FieldItem +import app.passwordstore.databinding.DecryptLayoutBinding +import app.passwordstore.ui.adapters.FieldItemAdapter +import app.passwordstore.util.extensions.isErr +import app.passwordstore.util.extensions.unsafeLazy +import app.passwordstore.util.extensions.viewBinding +import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.runCatching +import dagger.hilt.android.AndroidEntryPoint +import java.io.ByteArrayOutputStream +import java.io.File +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalTime::class) +@AndroidEntryPoint +class DecryptActivityV2 : BasePgpActivity() { + + private val binding by viewBinding(DecryptLayoutBinding::inflate) + @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory + @Inject lateinit var repository: CryptoRepository + private val relativeParentPath by unsafeLazy { getParentPath(fullPath, repoPath) } + + private var passwordEntry: PasswordEntry? = null + private var retries = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + title = name + with(binding) { + setContentView(root) + passwordCategory.text = relativeParentPath + passwordFile.text = name + passwordFile.setOnLongClickListener { + copyTextToClipboard(name) + true + } + } + decrypt(isError = false) + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.pgp_handler, menu) + passwordEntry?.let { entry -> + menu.findItem(R.id.edit_password).isVisible = true + if (!entry.password.isNullOrBlank()) { + menu.findItem(R.id.share_password_as_plaintext).isVisible = true + menu.findItem(R.id.copy_password).isVisible = true + } + } + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> onBackPressed() + R.id.edit_password -> editPassword() + R.id.share_password_as_plaintext -> shareAsPlaintext() + R.id.copy_password -> copyPasswordToClipboard(passwordEntry?.password) + else -> return super.onOptionsItemSelected(item) + } + return true + } + + /** + * Automatically finishes the activity 60 seconds after decryption succeeded to prevent + * information leaks from stale activities. + */ + private fun startAutoDismissTimer() { + lifecycleScope.launch { + delay(60.seconds) + finish() + } + } + + /** + * Edit the current password and hide all the fields populated by encrypted data so that when the + * result triggers they can be repopulated with new data. + */ + private fun editPassword() { + val intent = Intent(this, PasswordCreationActivityV2::class.java) + intent.putExtra("FILE_PATH", relativeParentPath) + intent.putExtra("REPO_PATH", repoPath) + intent.putExtra(PasswordCreationActivityV2.EXTRA_FILE_NAME, name) + intent.putExtra(PasswordCreationActivityV2.EXTRA_PASSWORD, passwordEntry?.password) + intent.putExtra( + PasswordCreationActivityV2.EXTRA_EXTRA_CONTENT, + passwordEntry?.extraContentString + ) + intent.putExtra(PasswordCreationActivityV2.EXTRA_EDITING, true) + startActivity(intent) + finish() + } + + private fun shareAsPlaintext() { + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, passwordEntry?.password) + type = "text/plain" + } + // Always show a picker to give the user a chance to cancel + startActivity( + Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to)) + ) + } + + private fun decrypt(isError: Boolean) { + if (retries < MAX_RETRIES) { + retries += 1 + } else { + finish() + } + val dialog = PasswordDialog() + if (isError) { + dialog.setError() + } + lifecycleScope.launch(Dispatchers.Main) { + dialog.password.collectLatest { value -> + if (value != null) { + if (runCatching { decrypt(value) }.isErr()) { + decrypt(isError = true) + } + } + } + } + dialog.show(supportFragmentManager, "PASSWORD_DIALOG") + } + + private suspend fun decrypt(password: String) { + val message = withContext(Dispatchers.IO) { File(fullPath).readBytes().inputStream() } + val result = + withContext(Dispatchers.IO) { + val outputStream = ByteArrayOutputStream() + repository.decrypt( + password, + message, + outputStream, + ) + outputStream + } + require(result.size() != 0) { "Incorrect password" } + startAutoDismissTimer() + + val entry = passwordEntryFactory.create(result.toByteArray()) + passwordEntry = entry + createPasswordUi(entry) + } + + private suspend fun createPasswordUi(entry: PasswordEntry) = + withContext(Dispatchers.Main) { + val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true) + invalidateOptionsMenu() + + val items = arrayListOf() + if (!entry.password.isNullOrBlank()) { + items.add(FieldItem.createPasswordField(entry.password!!)) + } + + if (entry.hasTotp()) { + items.add(FieldItem.createOtpField(entry.totp.first())) + } + + if (!entry.username.isNullOrBlank()) { + items.add(FieldItem.createUsernameField(entry.username!!)) + } + + entry.extraContent.forEach { (key, value) -> + items.add(FieldItem(key, value, FieldItem.ActionType.COPY)) + } + + val adapter = FieldItemAdapter(items, showPassword) { text -> copyTextToClipboard(text) } + binding.recyclerView.adapter = adapter + binding.recyclerView.itemAnimator = null + + if (entry.hasTotp()) { + entry.totp.onEach(adapter::updateOTPCode).launchIn(lifecycleScope) + } + } + + private companion object { + private const val MAX_RETRIES = 3 + } +} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/GetKeyIdsActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/GetKeyIdsActivity.kt new file mode 100644 index 00000000..2b25db0b --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/crypto/GetKeyIdsActivity.kt @@ -0,0 +1,78 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.crypto + +import android.content.Intent +import android.os.Bundle +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult +import androidx.lifecycle.lifecycleScope +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import logcat.LogPriority.ERROR +import logcat.asLog +import logcat.logcat +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpUtils +import org.openintents.openpgp.IOpenPgpService2 + +class GetKeyIdsActivity : BasePgpActivity() { + + private val userInteractionRequiredResult = + registerForActivityResult(StartIntentSenderForResult()) { result -> + if (result.data == null || result.resultCode == RESULT_CANCELED) { + setResult(RESULT_CANCELED, result.data) + finish() + return@registerForActivityResult + } + getKeyIds(result.data!!) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + bindToOpenKeychain(this) + } + + override fun onBound(service: IOpenPgpService2) { + super.onBound(service) + getKeyIds() + } + + override fun onError(e: Exception) { + logcat(ERROR) { e.asLog() } + } + + /** Get the Key ids from OpenKeychain */ + private fun getKeyIds(data: Intent = Intent()) { + data.action = OpenPgpApi.ACTION_GET_KEY_IDS + lifecycleScope.launch(Dispatchers.Main) { + val result = withContext(Dispatchers.IO) { checkNotNull(api).executeApi(data, null, null) } + when (result.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { + OpenPgpApi.RESULT_CODE_SUCCESS -> { + runCatching { + val ids = + result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS)?.map { + OpenPgpUtils.convertKeyIdToHex(it) + } + ?: emptyList() + val keyResult = Intent().putExtra(OpenPgpApi.EXTRA_KEY_IDS, ids.toTypedArray()) + setResult(RESULT_OK, keyResult) + finish() + } + .onFailure { e -> logcat(ERROR) { e.asLog() } } + } + OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val sender = getUserInteractionRequestIntent(result) + userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build()) + } + OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) + } + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt new file mode 100644 index 00000000..f432227e --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt @@ -0,0 +1,617 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.crypto + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.os.Build +import android.os.Bundle +import android.provider.MediaStore +import android.text.InputType +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult +import androidx.core.content.edit +import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.databinding.PasswordCreationActivityBinding +import app.passwordstore.ui.dialogs.DicewarePasswordGeneratorDialogFragment +import app.passwordstore.ui.dialogs.OtpImportDialogFragment +import app.passwordstore.ui.dialogs.PasswordGeneratorDialogFragment +import app.passwordstore.util.autofill.AutofillPreferences +import app.passwordstore.util.autofill.DirectoryStructure +import app.passwordstore.util.crypto.GpgIdentifier +import app.passwordstore.util.extensions.asLog +import app.passwordstore.util.extensions.base64 +import app.passwordstore.util.extensions.commitChange +import app.passwordstore.util.extensions.getString +import app.passwordstore.util.extensions.isInsideRepository +import app.passwordstore.util.extensions.snackbar +import app.passwordstore.util.extensions.unsafeLazy +import app.passwordstore.util.extensions.viewBinding +import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.google.zxing.BinaryBitmap +import com.google.zxing.LuminanceSource +import com.google.zxing.RGBLuminanceSource +import com.google.zxing.common.HybridBinarizer +import com.google.zxing.integration.android.IntentIntegrator +import com.google.zxing.integration.android.IntentIntegrator.QR_CODE +import com.google.zxing.qrcode.QRCodeReader +import dagger.hilt.android.AndroidEntryPoint +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import logcat.LogPriority.ERROR +import logcat.asLog +import logcat.logcat +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection + +@AndroidEntryPoint +class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { + + private val binding by viewBinding(PasswordCreationActivityBinding::inflate) + @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory + + private val suggestedName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) } + private val suggestedPass by unsafeLazy { intent.getStringExtra(EXTRA_PASSWORD) } + private val suggestedExtra by unsafeLazy { intent.getStringExtra(EXTRA_EXTRA_CONTENT) } + private val shouldGeneratePassword by unsafeLazy { + intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) + } + private val editing by unsafeLazy { intent.getBooleanExtra(EXTRA_EDITING, false) } + private val oldFileName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) } + private var oldCategory: String? = null + private var copy: Boolean = false + private var encryptionIntent: Intent = Intent() + + private val userInteractionRequiredResult = + registerForActivityResult(StartIntentSenderForResult()) { result -> + if (result.data == null) { + setResult(RESULT_CANCELED, null) + finish() + return@registerForActivityResult + } + + when (result.resultCode) { + RESULT_OK -> encrypt(result.data) + RESULT_CANCELED -> { + setResult(RESULT_CANCELED, result.data) + finish() + } + } + } + + private val otpImportAction = + registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + binding.otpImportButton.isVisible = false + val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data) + val contents = "${intentResult.contents}\n" + val currentExtras = binding.extraContent.text.toString() + if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') + binding.extraContent.append("\n$contents") + else binding.extraContent.append(contents) + snackbar(message = getString(R.string.otp_import_success)) + } else { + snackbar(message = getString(R.string.otp_import_failure)) + } + } + + private val imageImportAction = + registerForActivityResult(ActivityResultContracts.GetContent()) { imageUri -> + if (imageUri == null) { + snackbar(message = getString(R.string.otp_import_failure)) + return@registerForActivityResult + } + val bitmap = + if (Build.VERSION.SDK_INT >= 28) { + ImageDecoder.decodeBitmap(ImageDecoder.createSource(contentResolver, imageUri)) + .copy(Bitmap.Config.ARGB_8888, true) + } else { + @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(contentResolver, imageUri) + } + val intArray = IntArray(bitmap.width * bitmap.height) + // copy pixel data from the Bitmap into the 'intArray' array + bitmap.getPixels(intArray, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) + val source: LuminanceSource = RGBLuminanceSource(bitmap.width, bitmap.height, intArray) + val binaryBitmap = BinaryBitmap(HybridBinarizer(source)) + + val reader = QRCodeReader() + runCatching { + val result = reader.decode(binaryBitmap) + val text = result.text + val currentExtras = binding.extraContent.text.toString() + if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') + binding.extraContent.append("\n$text") + else binding.extraContent.append(text) + snackbar(message = getString(R.string.otp_import_success)) + binding.otpImportButton.isVisible = false + } + .onFailure { snackbar(message = getString(R.string.otp_import_failure)) } + } + + private val gpgKeySelectAction = + registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds -> + lifecycleScope.launch { + val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id") + withContext(Dispatchers.IO) { + gpgIdentifierFile.writeText((keyIds + "").joinToString("\n")) + } + commitChange( + getString( + R.string.git_commit_gpg_id, + getLongName( + gpgIdentifierFile.parentFile!!.absolutePath, + repoPath, + gpgIdentifierFile.name + ) + ) + ) + .onSuccess { encrypt(encryptionIntent) } + } + } + } else { + snackbar( + message = getString(R.string.gpg_key_select_mandatory), + length = Snackbar.LENGTH_LONG + ) + } + } + + private fun File.findTillRoot(fileName: String, rootPath: File): File? { + val gpgFile = File(this, fileName) + if (gpgFile.exists()) return gpgFile + + if (this.absolutePath == rootPath.absolutePath) { + return null + } + + val parent = parentFile + return if (parent != null && parent.exists()) { + parent.findTillRoot(fileName, rootPath) + } else { + null + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + bindToOpenKeychain(this) + title = + if (editing) getString(R.string.edit_password) else getString(R.string.new_password_title) + with(binding) { + setContentView(root) + generatePassword.setOnClickListener { generatePassword() } + otpImportButton.setOnClickListener { + supportFragmentManager.setFragmentResultListener( + OTP_RESULT_REQUEST_KEY, + this@PasswordCreationActivity + ) { requestKey, bundle -> + if (requestKey == OTP_RESULT_REQUEST_KEY) { + val contents = bundle.getString(RESULT) + val currentExtras = binding.extraContent.text.toString() + if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') + binding.extraContent.append("\n$contents") + else binding.extraContent.append(contents) + } + } + val hasCamera = packageManager?.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) == true + if (hasCamera) { + val items = + arrayOf( + getString(R.string.otp_import_qr_code), + getString(R.string.otp_import_from_file), + getString(R.string.otp_import_manual_entry), + ) + MaterialAlertDialogBuilder(this@PasswordCreationActivity) + .setItems(items) { _, index -> + when (index) { + 0 -> + otpImportAction.launch( + IntentIntegrator(this@PasswordCreationActivity) + .setOrientationLocked(false) + .setBeepEnabled(false) + .setDesiredBarcodeFormats(QR_CODE) + .createScanIntent() + ) + 1 -> imageImportAction.launch("image/*") + 2 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") + } + } + .show() + } else { + OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") + } + } + + directoryInputLayout.apply { + if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) { + isEnabled = true + } else { + setBackgroundColor(getColor(android.R.color.transparent)) + } + val path = getRelativePath(fullPath, repoPath) + // Keep empty path field visible if it is editable. + if (path.isEmpty() && !isEnabled) visibility = View.GONE + else { + directory.setText(path) + oldCategory = path + } + } + if (suggestedName != null) { + filename.setText(suggestedName) + } else { + filename.requestFocus() + } + // Allow the user to quickly switch between storing the username as the filename or + // in the encrypted extras. This only makes sense if the directory structure is + // FileBased. + if ( + suggestedName == null && + AutofillPreferences.directoryStructure(this@PasswordCreationActivity) == + DirectoryStructure.FileBased + ) { + encryptUsername.apply { + visibility = View.VISIBLE + setOnClickListener { + if (isChecked) { + // User wants to enable username encryption, so we add it to the + // encrypted extras as the first line. + val username = filename.text.toString() + val extras = "username:$username\n${extraContent.text}" + + filename.text?.clear() + extraContent.setText(extras) + } else { + // User wants to disable username encryption, so we extract the + // username from the encrypted extras and use it as the filename. + val entry = + passwordEntryFactory.create("PASSWORD\n${extraContent.text}".encodeToByteArray()) + val username = entry.username + + // username should not be null here by the logic in + // updateViewState, but it could still happen due to + // input lag. + if (username != null) { + filename.setText(username) + extraContent.setText(entry.extraContentWithoutAuthData) + } + } + } + } + } + suggestedPass?.let { + password.setText(it) + password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + suggestedExtra?.let { extraContent.setText(it) } + if (shouldGeneratePassword) { + generatePassword() + password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + } + listOf(binding.filename, binding.extraContent).forEach { + it.doAfterTextChanged { updateViewState() } + } + updateViewState() + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.pgp_handler_new_password, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + setResult(RESULT_CANCELED) + onBackPressed() + } + R.id.save_password -> { + copy = false + encrypt() + } + R.id.save_and_copy_password -> { + copy = true + encrypt() + } + else -> return super.onOptionsItemSelected(item) + } + return true + } + + private fun generatePassword() { + supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) { + requestKey, + bundle -> + if (requestKey == PASSWORD_RESULT_REQUEST_KEY) { + binding.password.setText(bundle.getString(RESULT)) + } + } + when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_CLASSIC) { + KEY_PWGEN_TYPE_CLASSIC -> + PasswordGeneratorDialogFragment().show(supportFragmentManager, "generator") + KEY_PWGEN_TYPE_DICEWARE -> + DicewarePasswordGeneratorDialogFragment().show(supportFragmentManager, "generator") + } + } + + private fun updateViewState() = + with(binding) { + // Use PasswordEntry to parse extras for username + val entry = + passwordEntryFactory.create("PLACEHOLDER\n${extraContent.text}".encodeToByteArray()) + encryptUsername.apply { + if (visibility != View.VISIBLE) return@apply + val hasUsernameInFileName = filename.text.toString().isNotBlank() + val hasUsernameInExtras = !entry.username.isNullOrBlank() + isEnabled = hasUsernameInFileName xor hasUsernameInExtras + isChecked = hasUsernameInExtras + } + otpImportButton.isVisible = !entry.hasTotp() + } + + /** Encrypts the password and the extra content */ + private fun encrypt(receivedIntent: Intent? = null) { + with(binding) { + val editName = filename.text.toString().trim() + val editPass = password.text.toString() + val editExtra = extraContent.text.toString() + + if (editName.isEmpty()) { + snackbar(message = resources.getString(R.string.file_toast_text)) + return@with + } else if (editName.contains('/')) { + snackbar(message = resources.getString(R.string.invalid_filename_text)) + return@with + } + + if (editPass.isEmpty() && editExtra.isEmpty()) { + snackbar(message = resources.getString(R.string.empty_toast_text)) + return@with + } + + if (copy) { + copyPasswordToClipboard(editPass) + } + + encryptionIntent = receivedIntent ?: Intent() + encryptionIntent.action = OpenPgpApi.ACTION_ENCRYPT + + // pass enters the key ID into `.gpg-id`. + val repoRoot = PasswordRepository.getRepositoryDirectory() + val gpgIdentifierFile = + File(repoRoot, directory.text.toString()).findTillRoot(".gpg-id", repoRoot) + ?: File(repoRoot, ".gpg-id").apply { createNewFile() } + val gpgIdentifiers = + gpgIdentifierFile + .readLines() + .filter { it.isNotBlank() } + .map { line -> + GpgIdentifier.fromString(line) + ?: run { + // The line being empty means this is most likely an empty `.gpg-id` + // file we created. Skip the validation so we can make the user add a + // real ID. + if (line.isEmpty()) return@run + if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) { + snackbar(message = resources.getString(R.string.short_key_ids_unsupported)) + } else { + snackbar(message = resources.getString(R.string.invalid_gpg_id)) + } + return@with + } + } + if (gpgIdentifiers.isEmpty()) { + gpgKeySelectAction.launch( + Intent(this@PasswordCreationActivity, GetKeyIdsActivity::class.java) + ) + return@with + } + val keyIds = + gpgIdentifiers.filterIsInstance().map { it.id }.toLongArray() + if (keyIds.isNotEmpty()) { + encryptionIntent.putExtra(OpenPgpApi.EXTRA_KEY_IDS, keyIds) + } + val userIds = + gpgIdentifiers.filterIsInstance().map { it.email }.toTypedArray() + if (userIds.isNotEmpty()) { + encryptionIntent.putExtra(OpenPgpApi.EXTRA_USER_IDS, userIds) + } + + encryptionIntent.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, false) + + val content = "$editPass\n$editExtra" + val inputStream = ByteArrayInputStream(content.toByteArray()) + val outputStream = ByteArrayOutputStream() + + val path = + when { + // If we allowed the user to edit the relative path, we have to consider it here + // instead + // of fullPath. + directoryInputLayout.isEnabled -> { + val editRelativePath = directory.text.toString().trim() + if (editRelativePath.isEmpty()) { + snackbar(message = resources.getString(R.string.path_toast_text)) + return + } + val passwordDirectory = File("$repoPath/${editRelativePath.trim('/')}") + if (!passwordDirectory.exists() && !passwordDirectory.mkdir()) { + snackbar(message = "Failed to create directory ${editRelativePath.trim('/')}") + return + } + + "${passwordDirectory.path}/$editName.gpg" + } + else -> "$fullPath/$editName.gpg" + } + + lifecycleScope.launch(Dispatchers.Main) { + val result = + withContext(Dispatchers.IO) { + checkNotNull(api).executeApi(encryptionIntent, inputStream, outputStream) + } + when (result.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { + OpenPgpApi.RESULT_CODE_SUCCESS -> { + runCatching { + val file = File(path) + // If we're not editing, this file should not already exist! + // Additionally, if we were editing and the incoming and outgoing + // filenames differ, it means we renamed. Ensure that the target + // doesn't already exist to prevent an accidental overwrite. + if ( + (!editing || (editing && suggestedName != file.nameWithoutExtension)) && + file.exists() + ) { + snackbar(message = getString(R.string.password_creation_duplicate_error)) + return@runCatching + } + + if (!file.isInsideRepository()) { + snackbar(message = getString(R.string.message_error_destination_outside_repo)) + return@runCatching + } + + withContext(Dispatchers.IO) { + file.outputStream().use { it.write(outputStream.toByteArray()) } + } + + // associate the new password name with the last name's timestamp in + // history + val preference = + getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) + val oldFilePathHash = + "$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg".base64() + val timestamp = preference.getString(oldFilePathHash) + if (timestamp != null) { + preference.edit { + remove(oldFilePathHash) + putString(file.absolutePath.base64(), timestamp) + } + } + + val returnIntent = Intent() + returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path) + returnIntent.putExtra(RETURN_EXTRA_NAME, editName) + returnIntent.putExtra( + RETURN_EXTRA_LONG_NAME, + getLongName(fullPath, repoPath, editName) + ) + + if (shouldGeneratePassword) { + val directoryStructure = + AutofillPreferences.directoryStructure(applicationContext) + val entry = passwordEntryFactory.create(content.encodeToByteArray()) + returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password) + val username = entry.username ?: directoryStructure.getUsernameFor(file) + returnIntent.putExtra(RETURN_EXTRA_USERNAME, username) + } + + if ( + directoryInputLayout.isVisible && + directoryInputLayout.isEnabled && + oldFileName != null + ) { + val oldFile = File("$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg") + if (oldFile.path != file.path && !oldFile.delete()) { + setResult(RESULT_CANCELED) + MaterialAlertDialogBuilder(this@PasswordCreationActivity) + .setTitle(R.string.password_creation_file_fail_title) + .setMessage( + getString(R.string.password_creation_file_delete_fail_message, oldFileName) + ) + .setCancelable(false) + .setPositiveButton(android.R.string.ok) { _, _ -> finish() } + .show() + return@runCatching + } + } + + val commitMessageRes = + if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text + lifecycleScope.launch { + commitChange( + resources.getString( + commitMessageRes, + getLongName(fullPath, repoPath, editName) + ) + ) + .onSuccess { + setResult(RESULT_OK, returnIntent) + finish() + } + } + } + .onFailure { e -> + if (e is IOException) { + logcat(ERROR) { e.asLog("Failed to write password file") } + setResult(RESULT_CANCELED) + MaterialAlertDialogBuilder(this@PasswordCreationActivity) + .setTitle(getString(R.string.password_creation_file_fail_title)) + .setMessage(getString(R.string.password_creation_file_write_fail_message)) + .setCancelable(false) + .setPositiveButton(android.R.string.ok) { _, _ -> finish() } + .show() + } else { + logcat(ERROR) { e.asLog() } + } + } + } + OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val sender = getUserInteractionRequestIntent(result) + userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build()) + } + OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) + } + } + } + } + + companion object { + + private const val KEY_PWGEN_TYPE_CLASSIC = "classic" + private const val KEY_PWGEN_TYPE_DICEWARE = "diceware" + const val PASSWORD_RESULT_REQUEST_KEY = "PASSWORD_GENERATOR" + const val OTP_RESULT_REQUEST_KEY = "OTP_IMPORT" + const val RESULT = "RESULT" + const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE" + const val RETURN_EXTRA_NAME = "NAME" + const val RETURN_EXTRA_LONG_NAME = "LONG_NAME" + const val RETURN_EXTRA_USERNAME = "USERNAME" + const val RETURN_EXTRA_PASSWORD = "PASSWORD" + const val EXTRA_FILE_NAME = "FILENAME" + const val EXTRA_PASSWORD = "PASSWORD" + const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT" + const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD" + const val EXTRA_EDITING = "EDITING" + } +} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivityV2.kt b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivityV2.kt new file mode 100644 index 00000000..7070ce7b --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivityV2.kt @@ -0,0 +1,479 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.crypto + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.os.Build +import android.os.Bundle +import android.provider.MediaStore +import android.text.InputType +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.core.content.edit +import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.data.crypto.CryptoRepository +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.databinding.PasswordCreationActivityBinding +import app.passwordstore.ui.dialogs.DicewarePasswordGeneratorDialogFragment +import app.passwordstore.ui.dialogs.OtpImportDialogFragment +import app.passwordstore.ui.dialogs.PasswordGeneratorDialogFragment +import app.passwordstore.util.autofill.AutofillPreferences +import app.passwordstore.util.autofill.DirectoryStructure +import app.passwordstore.util.extensions.asLog +import app.passwordstore.util.extensions.base64 +import app.passwordstore.util.extensions.commitChange +import app.passwordstore.util.extensions.getString +import app.passwordstore.util.extensions.isInsideRepository +import app.passwordstore.util.extensions.snackbar +import app.passwordstore.util.extensions.unsafeLazy +import app.passwordstore.util.extensions.viewBinding +import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.zxing.BinaryBitmap +import com.google.zxing.LuminanceSource +import com.google.zxing.RGBLuminanceSource +import com.google.zxing.common.HybridBinarizer +import com.google.zxing.integration.android.IntentIntegrator +import com.google.zxing.integration.android.IntentIntegrator.QR_CODE +import com.google.zxing.qrcode.QRCodeReader +import dagger.hilt.android.AndroidEntryPoint +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import logcat.LogPriority.ERROR +import logcat.asLog +import logcat.logcat + +@AndroidEntryPoint +class PasswordCreationActivityV2 : BasePgpActivity() { + + private val binding by viewBinding(PasswordCreationActivityBinding::inflate) + @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory + @Inject lateinit var repository: CryptoRepository + + private val suggestedName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) } + private val suggestedPass by unsafeLazy { intent.getStringExtra(EXTRA_PASSWORD) } + private val suggestedExtra by unsafeLazy { intent.getStringExtra(EXTRA_EXTRA_CONTENT) } + private val shouldGeneratePassword by unsafeLazy { + intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) + } + private val editing by unsafeLazy { intent.getBooleanExtra(EXTRA_EDITING, false) } + private val oldFileName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) } + private var oldCategory: String? = null + private var copy: Boolean = false + + private val otpImportAction = + registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + binding.otpImportButton.isVisible = false + val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data) + val contents = "${intentResult.contents}\n" + val currentExtras = binding.extraContent.text.toString() + if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') + binding.extraContent.append("\n$contents") + else binding.extraContent.append(contents) + snackbar(message = getString(R.string.otp_import_success)) + } else { + snackbar(message = getString(R.string.otp_import_failure)) + } + } + + private val imageImportAction = + registerForActivityResult(ActivityResultContracts.GetContent()) { imageUri -> + if (imageUri == null) { + snackbar(message = getString(R.string.otp_import_failure)) + return@registerForActivityResult + } + val bitmap = + if (Build.VERSION.SDK_INT >= 28) { + ImageDecoder.decodeBitmap(ImageDecoder.createSource(contentResolver, imageUri)) + .copy(Bitmap.Config.ARGB_8888, true) + } else { + @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(contentResolver, imageUri) + } + val intArray = IntArray(bitmap.width * bitmap.height) + // copy pixel data from the Bitmap into the 'intArray' array + bitmap.getPixels(intArray, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) + val source: LuminanceSource = RGBLuminanceSource(bitmap.width, bitmap.height, intArray) + val binaryBitmap = BinaryBitmap(HybridBinarizer(source)) + + val reader = QRCodeReader() + runCatching { + val result = reader.decode(binaryBitmap) + val text = result.text + val currentExtras = binding.extraContent.text.toString() + if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') + binding.extraContent.append("\n$text") + else binding.extraContent.append(text) + snackbar(message = getString(R.string.otp_import_success)) + binding.otpImportButton.isVisible = false + } + .onFailure { snackbar(message = getString(R.string.otp_import_failure)) } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + bindToOpenKeychain(this) + title = + if (editing) getString(R.string.edit_password) else getString(R.string.new_password_title) + with(binding) { + setContentView(root) + generatePassword.setOnClickListener { generatePassword() } + otpImportButton.setOnClickListener { + supportFragmentManager.setFragmentResultListener( + OTP_RESULT_REQUEST_KEY, + this@PasswordCreationActivityV2 + ) { requestKey, bundle -> + if (requestKey == OTP_RESULT_REQUEST_KEY) { + val contents = bundle.getString(RESULT) + val currentExtras = binding.extraContent.text.toString() + if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') + binding.extraContent.append("\n$contents") + else binding.extraContent.append(contents) + } + } + val hasCamera = packageManager?.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) == true + if (hasCamera) { + val items = + arrayOf( + getString(R.string.otp_import_qr_code), + getString(R.string.otp_import_from_file), + getString(R.string.otp_import_manual_entry), + ) + MaterialAlertDialogBuilder(this@PasswordCreationActivityV2) + .setItems(items) { _, index -> + when (index) { + 0 -> + otpImportAction.launch( + IntentIntegrator(this@PasswordCreationActivityV2) + .setOrientationLocked(false) + .setBeepEnabled(false) + .setDesiredBarcodeFormats(QR_CODE) + .createScanIntent() + ) + 1 -> imageImportAction.launch("image/*") + 2 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") + } + } + .show() + } else { + OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") + } + } + + directoryInputLayout.apply { + if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) { + isEnabled = true + } else { + setBackgroundColor(getColor(android.R.color.transparent)) + } + val path = getRelativePath(fullPath, repoPath) + // Keep empty path field visible if it is editable. + if (path.isEmpty() && !isEnabled) visibility = View.GONE + else { + directory.setText(path) + oldCategory = path + } + } + if (suggestedName != null) { + filename.setText(suggestedName) + } else { + filename.requestFocus() + } + // Allow the user to quickly switch between storing the username as the filename or + // in the encrypted extras. This only makes sense if the directory structure is + // FileBased. + if ( + suggestedName == null && + AutofillPreferences.directoryStructure(this@PasswordCreationActivityV2) == + DirectoryStructure.FileBased + ) { + encryptUsername.apply { + visibility = View.VISIBLE + setOnClickListener { + if (isChecked) { + // User wants to enable username encryption, so we add it to the + // encrypted extras as the first line. + val username = filename.text.toString() + val extras = "username:$username\n${extraContent.text}" + + filename.text?.clear() + extraContent.setText(extras) + } else { + // User wants to disable username encryption, so we extract the + // username from the encrypted extras and use it as the filename. + val entry = + passwordEntryFactory.create("PASSWORD\n${extraContent.text}".encodeToByteArray()) + val username = entry.username + + // username should not be null here by the logic in + // updateViewState, but it could still happen due to + // input lag. + if (username != null) { + filename.setText(username) + extraContent.setText(entry.extraContentWithoutAuthData) + } + } + } + } + } + suggestedPass?.let { + password.setText(it) + password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + suggestedExtra?.let { extraContent.setText(it) } + if (shouldGeneratePassword) { + generatePassword() + password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + } + listOf(binding.filename, binding.extraContent).forEach { + it.doAfterTextChanged { updateViewState() } + } + updateViewState() + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.pgp_handler_new_password, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + setResult(RESULT_CANCELED) + onBackPressed() + } + R.id.save_password -> { + copy = false + encrypt() + } + R.id.save_and_copy_password -> { + copy = true + encrypt() + } + else -> return super.onOptionsItemSelected(item) + } + return true + } + + private fun generatePassword() { + supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) { + requestKey, + bundle -> + if (requestKey == PASSWORD_RESULT_REQUEST_KEY) { + binding.password.setText(bundle.getString(RESULT)) + } + } + when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_CLASSIC) { + KEY_PWGEN_TYPE_CLASSIC -> + PasswordGeneratorDialogFragment().show(supportFragmentManager, "generator") + KEY_PWGEN_TYPE_DICEWARE -> + DicewarePasswordGeneratorDialogFragment().show(supportFragmentManager, "generator") + } + } + + private fun updateViewState() = + with(binding) { + // Use PasswordEntry to parse extras for username + val entry = + passwordEntryFactory.create("PLACEHOLDER\n${extraContent.text}".encodeToByteArray()) + encryptUsername.apply { + if (visibility != View.VISIBLE) return@apply + val hasUsernameInFileName = filename.text.toString().isNotBlank() + val hasUsernameInExtras = !entry.username.isNullOrBlank() + isEnabled = hasUsernameInFileName xor hasUsernameInExtras + isChecked = hasUsernameInExtras + } + otpImportButton.isVisible = !entry.hasTotp() + } + + /** Encrypts the password and the extra content */ + private fun encrypt() { + with(binding) { + val editName = filename.text.toString().trim() + val editPass = password.text.toString() + val editExtra = extraContent.text.toString() + + if (editName.isEmpty()) { + snackbar(message = resources.getString(R.string.file_toast_text)) + return@with + } else if (editName.contains('/')) { + snackbar(message = resources.getString(R.string.invalid_filename_text)) + return@with + } + + if (editPass.isEmpty() && editExtra.isEmpty()) { + snackbar(message = resources.getString(R.string.empty_toast_text)) + return@with + } + + if (copy) { + copyPasswordToClipboard(editPass) + } + + val content = "$editPass\n$editExtra" + val path = + when { + // If we allowed the user to edit the relative path, we have to consider it here + // instead + // of fullPath. + directoryInputLayout.isEnabled -> { + val editRelativePath = directory.text.toString().trim() + if (editRelativePath.isEmpty()) { + snackbar(message = resources.getString(R.string.path_toast_text)) + return + } + val passwordDirectory = File("$repoPath/${editRelativePath.trim('/')}") + if (!passwordDirectory.exists() && !passwordDirectory.mkdir()) { + snackbar(message = "Failed to create directory ${editRelativePath.trim('/')}") + return + } + + "${passwordDirectory.path}/$editName.gpg" + } + else -> "$fullPath/$editName.gpg" + } + + lifecycleScope.launch(Dispatchers.Main) { + runCatching { + val result = + withContext(Dispatchers.IO) { + val outputStream = ByteArrayOutputStream() + repository.encrypt(content.byteInputStream(), outputStream) + outputStream + } + val file = File(path) + // If we're not editing, this file should not already exist! + // Additionally, if we were editing and the incoming and outgoing + // filenames differ, it means we renamed. Ensure that the target + // doesn't already exist to prevent an accidental overwrite. + if ( + (!editing || (editing && suggestedName != file.nameWithoutExtension)) && file.exists() + ) { + snackbar(message = getString(R.string.password_creation_duplicate_error)) + return@runCatching + } + + if (!file.isInsideRepository()) { + snackbar(message = getString(R.string.message_error_destination_outside_repo)) + return@runCatching + } + + withContext(Dispatchers.IO) { file.writeBytes(result.toByteArray()) } + + // associate the new password name with the last name's timestamp in + // history + val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) + val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg".base64() + val timestamp = preference.getString(oldFilePathHash) + if (timestamp != null) { + preference.edit { + remove(oldFilePathHash) + putString(file.absolutePath.base64(), timestamp) + } + } + + val returnIntent = Intent() + returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path) + returnIntent.putExtra(RETURN_EXTRA_NAME, editName) + returnIntent.putExtra(RETURN_EXTRA_LONG_NAME, getLongName(fullPath, repoPath, editName)) + + if (shouldGeneratePassword) { + val directoryStructure = AutofillPreferences.directoryStructure(applicationContext) + val entry = passwordEntryFactory.create(content.encodeToByteArray()) + returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password) + val username = entry.username ?: directoryStructure.getUsernameFor(file) + returnIntent.putExtra(RETURN_EXTRA_USERNAME, username) + } + + if ( + directoryInputLayout.isVisible && + directoryInputLayout.isEnabled && + oldFileName != null + ) { + val oldFile = File("$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg") + if (oldFile.path != file.path && !oldFile.delete()) { + setResult(RESULT_CANCELED) + MaterialAlertDialogBuilder(this@PasswordCreationActivityV2) + .setTitle(R.string.password_creation_file_fail_title) + .setMessage( + getString(R.string.password_creation_file_delete_fail_message, oldFileName) + ) + .setCancelable(false) + .setPositiveButton(android.R.string.ok) { _, _ -> finish() } + .show() + return@runCatching + } + } + + val commitMessageRes = + if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text + lifecycleScope.launch { + commitChange( + resources.getString(commitMessageRes, getLongName(fullPath, repoPath, editName)) + ) + .onSuccess { + setResult(RESULT_OK, returnIntent) + finish() + } + } + } + .onFailure { e -> + if (e is IOException) { + logcat(ERROR) { e.asLog("Failed to write password file") } + setResult(RESULT_CANCELED) + MaterialAlertDialogBuilder(this@PasswordCreationActivityV2) + .setTitle(getString(R.string.password_creation_file_fail_title)) + .setMessage(getString(R.string.password_creation_file_write_fail_message)) + .setCancelable(false) + .setPositiveButton(android.R.string.ok) { _, _ -> finish() } + .show() + } else { + logcat(ERROR) { e.asLog() } + } + } + } + } + } + + companion object { + + private const val KEY_PWGEN_TYPE_CLASSIC = "classic" + private const val KEY_PWGEN_TYPE_DICEWARE = "diceware" + const val PASSWORD_RESULT_REQUEST_KEY = "PASSWORD_GENERATOR" + const val OTP_RESULT_REQUEST_KEY = "OTP_IMPORT" + const val RESULT = "RESULT" + const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE" + const val RETURN_EXTRA_NAME = "NAME" + const val RETURN_EXTRA_LONG_NAME = "LONG_NAME" + const val RETURN_EXTRA_USERNAME = "USERNAME" + const val RETURN_EXTRA_PASSWORD = "PASSWORD" + const val EXTRA_FILE_NAME = "FILENAME" + const val EXTRA_PASSWORD = "PASSWORD" + const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT" + const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD" + const val EXTRA_EDITING = "EDITING" + } +} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt b/app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt new file mode 100644 index 00000000..958ce0c1 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt @@ -0,0 +1,68 @@ +/* + * Copyright © 2014-2022 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.crypto + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.view.KeyEvent +import android.view.WindowManager +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.DialogFragment +import app.passwordstore.R +import app.passwordstore.databinding.DialogPasswordEntryBinding +import app.passwordstore.util.extensions.finish +import app.passwordstore.util.extensions.unsafeLazy +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** [DialogFragment] to request a password from the user and forward it along. */ +class PasswordDialog : DialogFragment() { + + private val binding by unsafeLazy { DialogPasswordEntryBinding.inflate(layoutInflater) } + private var isError: Boolean = false + private val _password = MutableStateFlow(null) + val password = _password.asStateFlow() + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val builder = MaterialAlertDialogBuilder(requireContext()) + builder.setView(binding.root) + builder.setTitle(R.string.password) + builder.setPositiveButton(android.R.string.ok) { _, _ -> tryEmitPassword() } + val dialog = builder.create() + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) + dialog.setOnShowListener { + if (isError) { + binding.passwordField.error = getString(R.string.git_operation_wrong_password) + } + binding.passwordEditText.doOnTextChanged { _, _, _, _ -> binding.passwordField.error = null } + binding.passwordEditText.setOnKeyListener { _, keyCode, _ -> + if (keyCode == KeyEvent.KEYCODE_ENTER) { + tryEmitPassword() + return@setOnKeyListener true + } + false + } + } + return dialog + } + + fun setError() { + isError = true + } + + override fun onCancel(dialog: DialogInterface) { + super.onCancel(dialog) + finish() + } + + @Suppress("ControlFlowWithEmptyBody") + private fun tryEmitPassword() { + do {} while (!_password.tryEmit(binding.passwordEditText.text.toString())) + dismissAllowingStateLoss() + } +} diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt b/app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt new file mode 100644 index 00000000..c35985cb --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt @@ -0,0 +1,166 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.dialogs + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import android.widget.FrameLayout +import androidx.annotation.StringRes +import androidx.core.view.isVisible +import app.passwordstore.R +import app.passwordstore.databinding.BasicBottomSheetBinding +import app.passwordstore.util.extensions.viewBinding +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +/** + * [BottomSheetDialogFragment] that exposes a simple [androidx.appcompat.app.AlertDialog] like API + * through [BasicBottomSheet.Builder] to create a similar UI, just at the bottom of the screen. + */ +class BasicBottomSheet +private constructor( + val title: String?, + val message: String, + val positiveButtonLabel: String?, + val negativeButtonLabel: String?, + val positiveButtonClickListener: View.OnClickListener?, + val negativeButtonClickListener: View.OnClickListener?, +) : BottomSheetDialogFragment() { + + private val binding by viewBinding(BasicBottomSheetBinding::bind) + + private var behavior: BottomSheetBehavior? = null + private val bottomSheetCallback = + object : BottomSheetBehavior.BottomSheetCallback() { + override fun onSlide(bottomSheet: View, slideOffset: Float) {} + + override fun onStateChanged(bottomSheet: View, newState: Int) { + if (newState == BottomSheetBehavior.STATE_COLLAPSED) { + dismiss() + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + if (savedInstanceState != null) dismiss() + return layoutInflater.inflate(R.layout.basic_bottom_sheet, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.viewTreeObserver.addOnGlobalLayoutListener( + object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + view.viewTreeObserver.removeOnGlobalLayoutListener(this) + val dialog = dialog as BottomSheetDialog? ?: return + behavior = dialog.behavior + behavior?.apply { + state = BottomSheetBehavior.STATE_EXPANDED + peekHeight = 0 + addBottomSheetCallback(bottomSheetCallback) + } + if (!title.isNullOrEmpty()) { + binding.bottomSheetTitle.isVisible = true + binding.bottomSheetTitle.text = title + } + binding.bottomSheetMessage.text = message + if (positiveButtonClickListener != null) { + positiveButtonLabel?.let { buttonLbl -> binding.bottomSheetOkButton.text = buttonLbl } + binding.bottomSheetOkButton.isVisible = true + binding.bottomSheetOkButton.setOnClickListener { + positiveButtonClickListener.onClick(it) + dismiss() + } + } + if (negativeButtonClickListener != null) { + binding.bottomSheetCancelButton.isVisible = true + negativeButtonLabel?.let { buttonLbl -> + binding.bottomSheetCancelButton.text = buttonLbl + } + binding.bottomSheetCancelButton.setOnClickListener { + negativeButtonClickListener.onClick(it) + dismiss() + } + } + } + } + ) + } + + override fun dismiss() { + super.dismiss() + behavior?.removeBottomSheetCallback(bottomSheetCallback) + } + + class Builder(val context: Context) { + + private var title: String? = null + private var message: String? = null + private var positiveButtonLabel: String? = null + private var negativeButtonLabel: String? = null + private var positiveButtonClickListener: View.OnClickListener? = null + private var negativeButtonClickListener: View.OnClickListener? = null + + fun setTitleRes(@StringRes titleRes: Int): Builder { + this.title = context.resources.getString(titleRes) + return this + } + + fun setTitle(title: String): Builder { + this.title = title + return this + } + + fun setMessageRes(@StringRes messageRes: Int): Builder { + this.message = context.resources.getString(messageRes) + return this + } + + fun setMessage(message: String): Builder { + this.message = message + return this + } + + fun setPositiveButtonClickListener( + buttonLabel: String? = null, + listener: View.OnClickListener + ): Builder { + this.positiveButtonClickListener = listener + this.positiveButtonLabel = buttonLabel + return this + } + + fun setNegativeButtonClickListener( + buttonLabel: String? = null, + listener: View.OnClickListener + ): Builder { + this.negativeButtonClickListener = listener + this.negativeButtonLabel = buttonLabel + return this + } + + fun build(): BasicBottomSheet { + require(message != null) { "Message needs to be set" } + return BasicBottomSheet( + title, + message!!, + positiveButtonLabel, + negativeButtonLabel, + positiveButtonClickListener, + negativeButtonClickListener + ) + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt new file mode 100644 index 00000000..bb3b6f6f --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt @@ -0,0 +1,90 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.dialogs + +import android.app.AlertDialog +import android.app.Dialog +import android.content.SharedPreferences +import android.graphics.Typeface +import android.os.Bundle +import androidx.core.content.edit +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.setFragmentResult +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.databinding.FragmentPwgenDicewareBinding +import app.passwordstore.injection.prefs.PasswordGeneratorPreferences +import app.passwordstore.passgen.diceware.DicewarePassphraseGenerator +import app.passwordstore.ui.crypto.PasswordCreationActivity +import app.passwordstore.util.extensions.getString +import app.passwordstore.util.settings.PreferenceKeys.DICEWARE_LENGTH +import app.passwordstore.util.settings.PreferenceKeys.DICEWARE_SEPARATOR +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.android.widget.afterTextChanges + +@AndroidEntryPoint +class DicewarePasswordGeneratorDialogFragment : DialogFragment() { + + @Inject lateinit var dicewareGenerator: DicewarePassphraseGenerator + @Inject @PasswordGeneratorPreferences lateinit var prefs: SharedPreferences + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val builder = MaterialAlertDialogBuilder(requireContext()) + + val binding = FragmentPwgenDicewareBinding.inflate(layoutInflater) + builder.setView(binding.root) + + binding.passwordSeparatorText.setText(prefs.getString(DICEWARE_SEPARATOR) ?: "-") + binding.passwordLengthText.setText(prefs.getInt(DICEWARE_LENGTH, 5).toString()) + binding.passwordText.typeface = Typeface.MONOSPACE + + merge( + binding.passwordLengthText.afterTextChanges(), + binding.passwordSeparatorText.afterTextChanges(), + ) + .onEach { generatePassword(binding) } + .launchIn(lifecycleScope) + return builder + .run { + setTitle(R.string.pwgen_title) + setPositiveButton(R.string.dialog_ok) { _, _ -> + setFragmentResult( + PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY, + bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}") + ) + } + setNeutralButton(R.string.dialog_cancel) { _, _ -> } + setNegativeButton(R.string.pwgen_generate, null) + create() + } + .apply { + setOnShowListener { + generatePassword(binding) + getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { generatePassword(binding) } + } + } + } + + private fun generatePassword(binding: FragmentPwgenDicewareBinding) { + val length = binding.passwordLengthText.text?.toString()?.toIntOrNull() ?: 5 + val separator = binding.passwordSeparatorText.text?.toString()?.getOrNull(0) ?: '-' + setPreferences(length, separator) + binding.passwordText.text = dicewareGenerator.generatePassphrase(length, separator) + } + + private fun setPreferences(length: Int, separator: Char) { + prefs.edit { + putInt(DICEWARE_LENGTH, length) + putString(DICEWARE_SEPARATOR, separator.toString()) + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt new file mode 100644 index 00000000..78177f10 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt @@ -0,0 +1,110 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.dialogs + +import android.app.Dialog +import android.content.Intent +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.crypto.BasePgpActivity +import app.passwordstore.ui.crypto.GetKeyIdsActivity +import app.passwordstore.ui.passwords.PasswordStore +import app.passwordstore.util.extensions.commitChange +import com.google.android.material.checkbox.MaterialCheckBox +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import java.io.File +import kotlinx.coroutines.launch +import me.msfjarvis.openpgpktx.util.OpenPgpApi + +class FolderCreationDialogFragment : DialogFragment() { + + private lateinit var newFolder: File + + private val keySelectAction = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == AppCompatActivity.RESULT_OK) { + result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds -> + val gpgIdentifierFile = File(newFolder, ".gpg-id") + gpgIdentifierFile.writeText(keyIds.joinToString("\n")) + if (PasswordRepository.repository != null) { + lifecycleScope.launch { + val repoPath = PasswordRepository.getRepositoryDirectory().absolutePath + requireActivity() + .commitChange( + getString( + R.string.git_commit_gpg_id, + BasePgpActivity.getLongName( + gpgIdentifierFile.parentFile!!.absolutePath, + repoPath, + gpgIdentifierFile.name + ) + ), + ) + dismiss() + } + } + } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext()) + alertDialogBuilder.setTitle(R.string.title_create_folder) + alertDialogBuilder.setView(R.layout.folder_dialog_fragment) + alertDialogBuilder.setPositiveButton(getString(R.string.button_create), null) + alertDialogBuilder.setNegativeButton(getString(android.R.string.cancel)) { _, _ -> dismiss() } + val dialog = alertDialogBuilder.create() + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + createDirectory(requireArguments().getString(CURRENT_DIR_EXTRA)!!) + } + } + return dialog + } + + private fun createDirectory(currentDir: String) { + val dialog = requireDialog() + val folderNameView = dialog.findViewById(R.id.folder_name_text) + val folderNameViewContainer = dialog.findViewById(R.id.folder_name_container) + newFolder = File("$currentDir/${folderNameView.text}") + folderNameViewContainer.error = + when { + newFolder.isFile -> getString(R.string.folder_creation_err_file_exists) + newFolder.isDirectory -> getString(R.string.folder_creation_err_folder_exists) + else -> null + } + if (folderNameViewContainer.error != null) return + newFolder.mkdirs() + (requireActivity() as PasswordStore).refreshPasswordList(newFolder) + if (dialog.findViewById(R.id.set_gpg_key).isChecked) { + keySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java)) + return + } else { + dismiss() + } + } + + companion object { + + private const val CURRENT_DIR_EXTRA = "CURRENT_DIRECTORY" + fun newInstance(startingDirectory: String): FolderCreationDialogFragment { + val extras = bundleOf(CURRENT_DIR_EXTRA to startingDirectory) + val fragment = FolderCreationDialogFragment() + fragment.arguments = extras + return fragment + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/ItemCreationBottomSheet.kt b/app/src/main/java/app/passwordstore/ui/dialogs/ItemCreationBottomSheet.kt new file mode 100644 index 00000000..82228f20 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/dialogs/ItemCreationBottomSheet.kt @@ -0,0 +1,77 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.dialogs + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import android.widget.FrameLayout +import androidx.core.os.bundleOf +import androidx.fragment.app.setFragmentResult +import app.passwordstore.R +import app.passwordstore.ui.passwords.PasswordFragment.Companion.ACTION_FOLDER +import app.passwordstore.ui.passwords.PasswordFragment.Companion.ACTION_KEY +import app.passwordstore.ui.passwords.PasswordFragment.Companion.ACTION_PASSWORD +import app.passwordstore.ui.passwords.PasswordFragment.Companion.ITEM_CREATION_REQUEST_KEY +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class ItemCreationBottomSheet : BottomSheetDialogFragment() { + + private var behavior: BottomSheetBehavior? = null + private val bottomSheetCallback = + object : BottomSheetBehavior.BottomSheetCallback() { + override fun onSlide(bottomSheet: View, slideOffset: Float) {} + + override fun onStateChanged(bottomSheet: View, newState: Int) { + if (newState == BottomSheetBehavior.STATE_COLLAPSED) { + dismiss() + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + if (savedInstanceState != null) dismiss() + return inflater.inflate(R.layout.item_create_sheet, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.viewTreeObserver.addOnGlobalLayoutListener( + object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + view.viewTreeObserver.removeOnGlobalLayoutListener(this) + val dialog = dialog as BottomSheetDialog? ?: return + behavior = dialog.behavior + behavior?.apply { + state = BottomSheetBehavior.STATE_EXPANDED + peekHeight = 0 + addBottomSheetCallback(bottomSheetCallback) + } + dialog.findViewById(R.id.create_folder)?.setOnClickListener { + setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_FOLDER)) + dismiss() + } + dialog.findViewById(R.id.create_password)?.setOnClickListener { + setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_PASSWORD)) + dismiss() + } + } + } + ) + } + + override fun dismiss() { + super.dismiss() + behavior?.removeBottomSheetCallback(bottomSheetCallback) + } +} diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt new file mode 100644 index 00000000..4d2413d4 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt @@ -0,0 +1,47 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.dialogs + +import android.app.Dialog +import android.net.Uri +import android.os.Bundle +import android.view.WindowManager +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.setFragmentResult +import app.passwordstore.databinding.FragmentManualOtpEntryBinding +import app.passwordstore.ui.crypto.PasswordCreationActivity +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +class OtpImportDialogFragment : DialogFragment() { + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val builder = MaterialAlertDialogBuilder(requireContext()) + val binding = FragmentManualOtpEntryBinding.inflate(layoutInflater) + builder.setView(binding.root) + builder.setPositiveButton(android.R.string.ok) { _, _ -> + setFragmentResult( + PasswordCreationActivity.OTP_RESULT_REQUEST_KEY, + bundleOf(PasswordCreationActivity.RESULT to getTOTPUri(binding)) + ) + } + val dialog = builder.create() + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) + return dialog + } + + private fun getTOTPUri(binding: FragmentManualOtpEntryBinding): String { + val secret = binding.secret.text.toString() + val account = binding.account.text.toString() + if (secret.isBlank()) return "" + val builder = Uri.Builder() + builder.scheme("otpauth") + builder.authority("totp") + builder.appendQueryParameter("secret", secret) + if (account.isNotBlank()) builder.appendQueryParameter("issuer", account) + return builder.build().toString() + } +} diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt new file mode 100644 index 00000000..06ce2d92 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt @@ -0,0 +1,149 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.dialogs + +import android.app.AlertDialog +import android.app.Dialog +import android.content.Context +import android.graphics.Typeface +import android.os.Bundle +import android.widget.CheckBox +import android.widget.EditText +import android.widget.Toast +import androidx.annotation.IdRes +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.content.edit +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.setFragmentResult +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.databinding.FragmentPwgenBinding +import app.passwordstore.passgen.random.MaxIterationsExceededException +import app.passwordstore.passgen.random.NoCharactersIncludedException +import app.passwordstore.passgen.random.PasswordGenerator +import app.passwordstore.passgen.random.PasswordLengthTooShortException +import app.passwordstore.passgen.random.PasswordOption +import app.passwordstore.ui.crypto.PasswordCreationActivity +import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.android.widget.afterTextChanges +import reactivecircus.flowbinding.android.widget.checkedChanges + +class PasswordGeneratorDialogFragment : DialogFragment() { + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val prefs = requireContext().getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE) + val builder = MaterialAlertDialogBuilder(requireContext()) + + val binding = FragmentPwgenBinding.inflate(layoutInflater) + builder.setView(binding.root) + + binding.numerals.isChecked = !prefs.getBoolean(PasswordOption.NoDigits.key, false) + binding.symbols.isChecked = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false) + binding.uppercase.isChecked = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false) + binding.lowercase.isChecked = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false) + binding.ambiguous.isChecked = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false) + binding.pronounceable.isChecked = !prefs.getBoolean(PasswordOption.FullyRandom.key, true) + binding.lengthNumber.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString()) + binding.passwordText.typeface = Typeface.MONOSPACE + + merge( + binding.numerals.checkedChanges().skipInitialValue(), + binding.symbols.checkedChanges().skipInitialValue(), + binding.uppercase.checkedChanges().skipInitialValue(), + binding.lowercase.checkedChanges().skipInitialValue(), + binding.ambiguous.checkedChanges().skipInitialValue(), + binding.pronounceable.checkedChanges().skipInitialValue(), + binding.lengthNumber.afterTextChanges().skipInitialValue(), + ) + .onEach { generate(binding.passwordText) } + .launchIn(lifecycleScope) + + return builder + .run { + setTitle(R.string.pwgen_title) + setPositiveButton(R.string.dialog_ok) { _, _ -> + setFragmentResult( + PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY, + bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}") + ) + } + setNeutralButton(R.string.dialog_cancel) { _, _ -> } + setNegativeButton(R.string.pwgen_generate, null) + create() + } + .apply { + setOnShowListener { + generate(binding.passwordText) + getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { + generate(binding.passwordText) + } + } + } + } + + private fun generate(passwordField: AppCompatTextView) { + val passwordOptions = getSelectedOptions() + val passwordLength = getLength() + setPrefs(requireContext(), passwordOptions, passwordLength) + passwordField.text = + runCatching { PasswordGenerator.generate(passwordOptions, passwordLength) } + .getOrElse { exception -> + val errorText = + when (exception) { + is MaxIterationsExceededException -> + requireContext().getString(R.string.pwgen_max_iterations_exceeded) + is NoCharactersIncludedException -> + requireContext().getString(R.string.pwgen_no_chars_error) + is PasswordLengthTooShortException -> + requireContext().getString(R.string.pwgen_length_too_short_error) + else -> requireContext().getString(R.string.pwgen_some_error_occurred) + } + Toast.makeText(requireActivity(), errorText, Toast.LENGTH_SHORT).show() + "" + } + } + + private fun isChecked(@IdRes id: Int): Boolean { + return requireDialog().findViewById(id).isChecked + } + + private fun getSelectedOptions(): List { + return listOfNotNull( + PasswordOption.NoDigits.takeIf { !isChecked(R.id.numerals) }, + PasswordOption.AtLeastOneSymbol.takeIf { isChecked(R.id.symbols) }, + PasswordOption.NoUppercaseLetters.takeIf { !isChecked(R.id.uppercase) }, + PasswordOption.NoAmbiguousCharacters.takeIf { !isChecked(R.id.ambiguous) }, + PasswordOption.FullyRandom.takeIf { !isChecked(R.id.pronounceable) }, + PasswordOption.NoLowercaseLetters.takeIf { !isChecked(R.id.lowercase) } + ) + } + + private fun getLength(): Int { + val lengthText = requireDialog().findViewById(R.id.lengthNumber).text.toString() + return lengthText.toIntOrNull()?.takeIf { it >= 0 } ?: PasswordGenerator.DEFAULT_LENGTH + } + + /** + * Enables the [PasswordOption]s in [options] and sets [targetLength] as the length for generated + * passwords. + */ + private fun setPrefs(ctx: Context, options: List, targetLength: Int): Boolean { + ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit { + for (possibleOption in PasswordOption.values()) putBoolean( + possibleOption.key, + possibleOption in options + ) + putInt("length", targetLength) + } + return true + } +} diff --git a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt new file mode 100644 index 00000000..009267e2 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt @@ -0,0 +1,65 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.folderselect + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.commit +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.passwords.PASSWORD_FRAGMENT_TAG +import app.passwordstore.ui.passwords.PasswordStore + +class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) { + + private lateinit var passwordList: SelectFolderFragment + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + passwordList = SelectFolderFragment() + val args = Bundle() + args.putString( + PasswordStore.REQUEST_ARG_PATH, + PasswordRepository.getRepositoryDirectory().absolutePath + ) + + passwordList.arguments = args + + supportActionBar?.show() + + supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + + supportFragmentManager.commit { + replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG) + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.pgp_handler_select_folder, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + setResult(RESULT_CANCELED) + onBackPressed() + } + R.id.crypto_select -> selectFolder() + else -> return super.onOptionsItemSelected(item) + } + return true + } + + private fun selectFolder() { + intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir.absolutePath) + setResult(RESULT_OK, intent) + finish() + } +} diff --git a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt new file mode 100644 index 00000000..577777f7 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt @@ -0,0 +1,86 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.folderselect + +import android.content.Context +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import app.passwordstore.R +import app.passwordstore.data.password.PasswordItem +import app.passwordstore.databinding.PasswordRecyclerViewBinding +import app.passwordstore.ui.adapters.PasswordItemRecyclerAdapter +import app.passwordstore.ui.passwords.PasswordStore +import app.passwordstore.util.extensions.viewBinding +import app.passwordstore.util.viewmodel.ListMode +import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import java.io.File +import me.zhanghai.android.fastscroll.FastScrollerBuilder + +class SelectFolderFragment : Fragment(R.layout.password_recycler_view) { + + private val binding by viewBinding(PasswordRecyclerViewBinding::bind) + private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter + private lateinit var listener: OnFragmentInteractionListener + + private val model: SearchableRepositoryViewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.fab.hide() + recyclerAdapter = + PasswordItemRecyclerAdapter(lifecycleScope).onItemClicked { _, item -> + listener.onFragmentInteraction(item) + } + binding.passRecycler.apply { + layoutManager = LinearLayoutManager(requireContext()) + itemAnimator = null + adapter = recyclerAdapter + } + + FastScrollerBuilder(binding.passRecycler).build() + registerForContextMenu(binding.passRecycler) + + val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) + model.navigateTo(File(path), listMode = ListMode.DirectoriesOnly, pushPreviousLocation = false) + model.searchResult.observe(viewLifecycleOwner) { result -> + recyclerAdapter.submitList(result.passwordItems) + } + } + + override fun onAttach(context: Context) { + super.onAttach(context) + runCatching { + listener = + object : OnFragmentInteractionListener { + override fun onFragmentInteraction(item: PasswordItem) { + if (item.type == PasswordItem.TYPE_CATEGORY) { + model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly) + (requireActivity() as AppCompatActivity) + .supportActionBar + ?.setDisplayHomeAsUpEnabled(true) + } + } + } + } + .onFailure { + throw ClassCastException("$context must implement OnFragmentInteractionListener") + } + } + + val currentDir: File + get() = model.currentDir.value!! + + interface OnFragmentInteractionListener { + + fun onFragmentInteraction(item: PasswordItem) + } +} diff --git a/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt b/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt new file mode 100644 index 00000000..fb9cfde3 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt @@ -0,0 +1,185 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.git.base + +import android.content.SharedPreferences +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import app.passwordstore.R +import app.passwordstore.injection.prefs.GitPreferences +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.git.ErrorMessages +import app.passwordstore.util.git.operation.BreakOutOfDetached +import app.passwordstore.util.git.operation.CloneOperation +import app.passwordstore.util.git.operation.GcOperation +import app.passwordstore.util.git.operation.PullOperation +import app.passwordstore.util.git.operation.PushOperation +import app.passwordstore.util.git.operation.ResetToRemoteOperation +import app.passwordstore.util.git.operation.SyncOperation +import app.passwordstore.util.git.sshj.ContinuationContainerActivity +import app.passwordstore.util.settings.GitSettings +import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.andThen +import com.github.michaelbull.result.mapError +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import logcat.asLog +import logcat.logcat +import net.schmizz.sshj.common.DisconnectReason +import net.schmizz.sshj.common.SSHException +import net.schmizz.sshj.transport.TransportException +import net.schmizz.sshj.userauth.UserAuthException + +/** + * Abstract [AppCompatActivity] that holds some information that is commonly shared across + * git-related tasks and makes sense to be held here. + */ +@AndroidEntryPoint +abstract class BaseGitActivity : ContinuationContainerActivity() { + + /** Enum of possible Git operations than can be run through [launchGitOperation]. */ + enum class GitOp { + BREAK_OUT_OF_DETACHED, + CLONE, + PULL, + PUSH, + RESET, + SYNC, + GC, + } + + @Inject lateinit var gitSettings: GitSettings + @GitPreferences @Inject lateinit var gitPrefs: SharedPreferences + + /** + * Attempt to launch the requested Git operation. + * @param operation The type of git operation to launch + */ + suspend fun launchGitOperation(operation: GitOp): Result { + if (gitSettings.url == null) { + return Err(IllegalStateException("Git url is not set!")) + } + if (operation == GitOp.SYNC && !gitSettings.useMultiplexing) { + // If the server does not support multiple SSH channels per connection, we cannot run + // a sync operation without reconnecting and thus break sync into its two parts. + return launchGitOperation(GitOp.PULL).andThen { launchGitOperation(GitOp.PUSH) } + } + val op = + when (operation) { + GitOp.CLONE -> CloneOperation(this, gitSettings.url!!) + GitOp.PULL -> PullOperation(this, gitSettings.rebaseOnPull) + GitOp.PUSH -> PushOperation(this) + GitOp.SYNC -> SyncOperation(this, gitSettings.rebaseOnPull) + GitOp.BREAK_OUT_OF_DETACHED -> BreakOutOfDetached(this) + GitOp.RESET -> ResetToRemoteOperation(this) + GitOp.GC -> GcOperation(this) + } + return (if (op.requiresAuth) { + op.executeAfterAuthentication(gitSettings.authMode) + } else { + op.execute() + }) + .mapError(::transformGitError) + } + + fun finishOnSuccessHandler(@Suppress("UNUSED_PARAMETER") nothing: Unit) { + finish() + } + + suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) { + val error = rootCauseException(err) + if (!isExplicitlyUserInitiatedError(error)) { + gitPrefs.edit { remove(PreferenceKeys.HTTPS_PASSWORD) } + sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } + logcat { error.asLog() } + withContext(Dispatchers.Main) { + MaterialAlertDialogBuilder(this@BaseGitActivity).run { + setTitle(resources.getString(R.string.jgit_error_dialog_title)) + setMessage(ErrorMessages[error]) + setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> } + setOnDismissListener { onPromptDone() } + show() + } + } + } else { + onPromptDone() + } + } + + /** + * Takes the result of [launchGitOperation] and applies any necessary transformations on the + * [throwable] returned from it + */ + private fun transformGitError(throwable: Throwable): Throwable { + val err = rootCauseException(throwable) + return when { + err.message?.contains("cannot open additional channels") == true -> { + gitSettings.useMultiplexing = false + SSHException( + DisconnectReason.TOO_MANY_CONNECTIONS, + "The server does not support multiple Git operations per SSH session. Please try again, a slower fallback mode will be used." + ) + } + err.message?.contains("int org.eclipse.jgit.lib.AnyObjectId.w1") == true -> { + IllegalStateException( + "Your local repository appears to be an incomplete Git clone, please delete and re-clone from settings" + ) + } + err is TransportException && + err.disconnectReason == DisconnectReason.HOST_KEY_NOT_VERIFIABLE -> { + SSHException( + DisconnectReason.HOST_KEY_NOT_VERIFIABLE, + "WARNING: The remote host key has changed. If this is expected, please go to Git server settings and clear the saved host key." + ) + } + else -> { + err + } + } + } + + /** + * Check if a given [Throwable] is the result of an error caused by the user cancelling the + * operation. + */ + private fun isExplicitlyUserInitiatedError(throwable: Throwable): Boolean { + var cause: Throwable? = throwable + while (cause != null) { + if ( + cause is SSHException && cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER + ) + return true + cause = cause.cause + } + return false + } + + /** + * Get the real root cause of a [Throwable] by traversing until known wrapping exceptions are no + * longer found. + */ + private fun rootCauseException(throwable: Throwable): Throwable { + var rootCause = throwable + // JGit's InvalidRemoteException and TransportException hide the more helpful SSHJ + // exceptions. + // Also, SSHJ's UserAuthException about exhausting available authentication methods hides + // more useful exceptions. + while ( + (rootCause is org.eclipse.jgit.errors.TransportException || + rootCause is org.eclipse.jgit.api.errors.TransportException || + rootCause is org.eclipse.jgit.api.errors.InvalidRemoteException || + (rootCause is UserAuthException && + rootCause.message == "Exhausted available authentication methods")) + ) { + rootCause = rootCause.cause ?: break + } + return rootCause + } +} diff --git a/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt b/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt new file mode 100644 index 00000000..2b5ecae5 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt @@ -0,0 +1,159 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.git.config + +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Patterns +import android.view.MenuItem +import androidx.core.os.postDelayed +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.databinding.ActivityGitConfigBinding +import app.passwordstore.ui.git.base.BaseGitActivity +import app.passwordstore.ui.git.log.GitLogActivity +import app.passwordstore.util.extensions.viewBinding +import com.github.michaelbull.result.fold +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch +import logcat.LogPriority.ERROR +import logcat.logcat +import org.eclipse.jgit.lib.Constants +import org.eclipse.jgit.lib.Repository +import org.eclipse.jgit.lib.RepositoryState + +class GitConfigActivity : BaseGitActivity() { + + private val binding by viewBinding(ActivityGitConfigBinding::inflate) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + if (gitSettings.authorName.isEmpty()) binding.gitUserName.requestFocus() + else binding.gitUserName.setText(gitSettings.authorName) + binding.gitUserEmail.setText(gitSettings.authorEmail) + setupTools() + binding.saveButton.setOnClickListener { + val email = binding.gitUserEmail.text.toString().trim() + val name = binding.gitUserName.text.toString().trim() + if (!email.matches(Patterns.EMAIL_ADDRESS.toRegex())) { + MaterialAlertDialogBuilder(this) + .setMessage(getString(R.string.invalid_email_dialog_text)) + .setPositiveButton(getString(R.string.dialog_ok), null) + .show() + } else { + gitSettings.authorEmail = email + gitSettings.authorName = name + Snackbar.make( + binding.root, + getString(R.string.git_server_config_save_success), + Snackbar.LENGTH_SHORT + ) + .show() + Handler(Looper.getMainLooper()).postDelayed(500) { finish() } + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + onBackPressed() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + /** Sets up the UI components of the tools section. */ + private fun setupTools() { + val repo = PasswordRepository.repository + if (repo != null) { + binding.gitHeadStatus.text = headStatusMsg(repo) + // enable the abort button only if we're rebasing or merging + val needsAbort = + repo.repositoryState.isRebasing || repo.repositoryState == RepositoryState.MERGING + binding.gitAbortRebase.isEnabled = needsAbort + binding.gitAbortRebase.alpha = if (needsAbort) 1.0f else 0.5f + } + binding.gitLog.setOnClickListener { + runCatching { startActivity(Intent(this, GitLogActivity::class.java)) } + .onFailure { ex -> logcat(ERROR) { "Failed to start GitLogActivity\n${ex}" } } + } + binding.gitAbortRebase.setOnClickListener { + lifecycleScope.launch { + launchGitOperation(GitOp.BREAK_OUT_OF_DETACHED) + .fold( + success = { + MaterialAlertDialogBuilder(this@GitConfigActivity).run { + setTitle(resources.getString(R.string.git_abort_and_push_title)) + setMessage( + resources.getString( + R.string.git_break_out_of_detached_success, + gitSettings.branch, + "conflicting-${gitSettings.branch}-...", + ) + ) + setOnDismissListener { finish() } + setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> } + show() + } + }, + failure = { err -> promptOnErrorHandler(err) { finish() } }, + ) + } + } + binding.gitResetToRemote.setOnClickListener { + lifecycleScope.launch { + launchGitOperation(GitOp.RESET) + .fold( + success = ::finishOnSuccessHandler, + failure = { err -> promptOnErrorHandler(err) { finish() } }, + ) + } + } + binding.gitGc.setOnClickListener { + lifecycleScope.launch { + launchGitOperation(GitOp.GC) + .fold( + success = ::finishOnSuccessHandler, + failure = { err -> promptOnErrorHandler(err) { finish() } }, + ) + } + } + } + + /** + * Returns a user-friendly message about the current state of HEAD. + * + * The state is recognized to be either pointing to a branch or detached. + */ + private fun headStatusMsg(repo: Repository): String { + return runCatching { + val headRef = repo.getRef(Constants.HEAD) + if (headRef.isSymbolic) { + val branchName = headRef.target.name + val shortBranchName = Repository.shortenRefName(branchName) + getString(R.string.git_head_on_branch, shortBranchName) + } else { + val commitHash = headRef.objectId.abbreviate(8).name() + getString(R.string.git_head_detached, commitHash) + } + } + .getOrElse { ex -> + logcat(ERROR) { "Error getting HEAD reference\n${ex}" } + getString(R.string.git_head_missing) + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt b/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt new file mode 100644 index 00000000..9cac0fdd --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt @@ -0,0 +1,305 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.git.config + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.MenuItem +import android.view.View +import androidx.core.os.postDelayed +import androidx.core.view.isVisible +import androidx.core.widget.doOnTextChanged +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.databinding.ActivityGitCloneBinding +import app.passwordstore.ui.dialogs.BasicBottomSheet +import app.passwordstore.ui.git.base.BaseGitActivity +import app.passwordstore.util.extensions.snackbar +import app.passwordstore.util.extensions.viewBinding +import app.passwordstore.util.settings.AuthMode +import app.passwordstore.util.settings.GitSettings +import app.passwordstore.util.settings.Protocol +import com.github.michaelbull.result.fold +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import logcat.LogPriority.ERROR +import logcat.asLog +import logcat.logcat + +/** + * Activity that encompasses both the initial clone as well as editing the server config for future + * changes. + */ +class GitServerConfigActivity : BaseGitActivity() { + + private val binding by viewBinding(ActivityGitCloneBinding::inflate) + + private lateinit var newAuthMode: AuthMode + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val isClone = intent?.extras?.getBoolean("cloning") ?: false + if (isClone) { + binding.saveButton.text = getString(R.string.clone_button) + } + setContentView(binding.root) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + newAuthMode = gitSettings.authMode + + binding.authModeGroup.apply { + when (newAuthMode) { + AuthMode.SshKey -> check(binding.authModeSshKey.id) + AuthMode.Password -> check(binding.authModePassword.id) + AuthMode.OpenKeychain -> check(binding.authModeOpenKeychain.id) + AuthMode.None -> check(View.NO_ID) + } + addOnButtonCheckedListener { _, checkedId, isChecked -> + if (!isChecked) { + newAuthMode = AuthMode.None + return@addOnButtonCheckedListener + } + when (checkedId) { + binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey + binding.authModeOpenKeychain.id -> newAuthMode = AuthMode.OpenKeychain + binding.authModePassword.id -> newAuthMode = AuthMode.Password + View.NO_ID -> newAuthMode = AuthMode.None + } + } + } + + binding.serverUrl.setText( + gitSettings.url.also { + if (it.isNullOrEmpty()) return@also + setAuthModes(it.startsWith("http://") || it.startsWith("https://")) + } + ) + binding.serverBranch.setText(gitSettings.branch) + + binding.serverUrl.doOnTextChanged { text, _, _, _ -> + if (text.isNullOrEmpty()) return@doOnTextChanged + setAuthModes(text.startsWith("http://") || text.startsWith("https://")) + } + + binding.clearHostKeyButton.isVisible = gitSettings.hasSavedHostKey() + binding.clearHostKeyButton.setOnClickListener { + gitSettings.clearSavedHostKey() + Snackbar.make( + binding.root, + getString(R.string.clear_saved_host_key_success), + Snackbar.LENGTH_LONG + ) + .show() + it.isVisible = false + } + binding.saveButton.setOnClickListener { + val newUrl = binding.serverUrl.text.toString().trim() + // If url is of type john_doe@example.org:12435/path/to/repo, then not adding `ssh://` + // in the beginning will cause the port to be seen as part of the path. Let users know + // about it and offer a quickfix. + if (newUrl.contains(PORT_REGEX)) { + if (newUrl.startsWith("https://")) { + BasicBottomSheet.Builder(this) + .setTitleRes(R.string.https_scheme_with_port_title) + .setMessageRes(R.string.https_scheme_with_port_message) + .setPositiveButtonClickListener { + binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/")) + } + .build() + .show(supportFragmentManager, "SSH_SCHEME_WARNING") + return@setOnClickListener + } else if (!newUrl.startsWith("ssh://")) { + BasicBottomSheet.Builder(this) + .setTitleRes(R.string.ssh_scheme_needed_title) + .setMessageRes(R.string.ssh_scheme_needed_message) + .setPositiveButtonClickListener { + @Suppress("SetTextI18n") binding.serverUrl.setText("ssh://$newUrl") + } + .build() + .show(supportFragmentManager, "SSH_SCHEME_WARNING") + return@setOnClickListener + } + } + if (newUrl.startsWith("git://")) { + BasicBottomSheet.Builder(this) + .setTitleRes(R.string.git_scheme_disallowed_title) + .setMessageRes(R.string.git_scheme_disallowed_message) + .setPositiveButtonClickListener {} + .build() + .show(supportFragmentManager, "SSH_SCHEME_WARNING") + return@setOnClickListener + } + when ( + val updateResult = + gitSettings.updateConnectionSettingsIfValid( + newAuthMode = newAuthMode, + newUrl = binding.serverUrl.text.toString().trim(), + newBranch = binding.serverBranch.text.toString().trim() + ) + ) { + GitSettings.UpdateConnectionSettingsResult.FailedToParseUrl -> { + Snackbar.make( + binding.root, + getString(R.string.git_server_config_save_error), + Snackbar.LENGTH_LONG + ) + .show() + } + is GitSettings.UpdateConnectionSettingsResult.MissingUsername -> { + when (updateResult.newProtocol) { + Protocol.Https -> + BasicBottomSheet.Builder(this) + .setTitleRes(R.string.ssh_scheme_needed_title) + .setMessageRes(R.string.git_server_config_save_missing_username_https) + .setPositiveButtonClickListener {} + .build() + .show(supportFragmentManager, "HTTPS_MISSING_USERNAME") + Protocol.Ssh -> + BasicBottomSheet.Builder(this) + .setTitleRes(R.string.ssh_scheme_needed_title) + .setMessageRes(R.string.git_server_config_save_missing_username_ssh) + .setPositiveButtonClickListener {} + .build() + .show(supportFragmentManager, "SSH_MISSING_USERNAME") + } + } + GitSettings.UpdateConnectionSettingsResult.Valid -> { + if (isClone && PasswordRepository.repository == null) PasswordRepository.initialize() + if (!isClone) { + Snackbar.make( + binding.root, + getString(R.string.git_server_config_save_success), + Snackbar.LENGTH_SHORT + ) + .show() + Handler(Looper.getMainLooper()).postDelayed(500) { finish() } + } else { + cloneRepository() + } + } + is GitSettings.UpdateConnectionSettingsResult.AuthModeMismatch -> { + val message = + getString( + R.string.git_server_config_save_auth_mode_mismatch, + updateResult.newProtocol, + updateResult.validModes.joinToString(", "), + ) + Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show() + } + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + onBackPressed() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private fun setAuthModes(isHttps: Boolean) = + with(binding) { + if (isHttps) { + authModeSshKey.isVisible = false + authModeOpenKeychain.isVisible = false + authModePassword.isVisible = true + if (authModeGroup.checkedButtonId != authModePassword.id) authModeGroup.check(View.NO_ID) + } else { + authModeSshKey.isVisible = true + authModeOpenKeychain.isVisible = true + authModePassword.isVisible = true + if (authModeGroup.checkedButtonId == View.NO_ID) authModeGroup.check(authModeSshKey.id) + } + } + + /** Clones the repository, the directory exists, deletes it */ + private fun cloneRepository() { + val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory()) + val localDirFiles = localDir.listFiles() ?: emptyArray() + // Warn if non-empty folder unless it's a just-initialized store that has just a .git folder + if ( + localDir.exists() && + localDirFiles.isNotEmpty() && + !(localDirFiles.size == 1 && localDirFiles[0].name == ".git") + ) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.dialog_delete_title) + .setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString())) + .setCancelable(false) + .setPositiveButton(R.string.dialog_delete) { dialog, _ -> + runCatching { + lifecycleScope.launch { + val snackbar = + snackbar( + message = getString(R.string.delete_directory_progress_text), + length = Snackbar.LENGTH_INDEFINITE + ) + withContext(Dispatchers.IO) { localDir.deleteRecursively() } + snackbar.dismiss() + launchGitOperation(GitOp.CLONE) + .fold( + success = { + setResult(RESULT_OK) + finish() + }, + failure = { err -> promptOnErrorHandler(err) { finish() } } + ) + } + } + .onFailure { e -> + e.printStackTrace() + MaterialAlertDialogBuilder(this).setMessage(e.message).show() + } + dialog.cancel() + } + .setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ -> dialog.cancel() } + .show() + } else { + runCatching { + // Silently delete & replace the lone .git folder if it exists + if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") { + localDir.deleteRecursively() + } + } + .onFailure { e -> + logcat(ERROR) { e.asLog() } + MaterialAlertDialogBuilder(this).setMessage(e.message).show() + } + lifecycleScope.launch { + launchGitOperation(GitOp.CLONE) + .fold( + success = { + setResult(RESULT_OK) + finish() + }, + failure = { promptOnErrorHandler(it) }, + ) + } + } + } + + companion object { + + private val PORT_REGEX = ":[0-9]{1,5}/".toRegex() + + fun createCloneIntent(context: Context): Intent { + return Intent(context, GitServerConfigActivity::class.java).apply { + putExtra("cloning", true) + } + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/git/log/GitLogActivity.kt b/app/src/main/java/app/passwordstore/ui/git/log/GitLogActivity.kt new file mode 100644 index 00000000..e160f16a --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/git/log/GitLogActivity.kt @@ -0,0 +1,49 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.git.log + +import android.os.Bundle +import android.view.MenuItem +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import app.passwordstore.databinding.ActivityGitLogBinding +import app.passwordstore.ui.git.base.BaseGitActivity +import app.passwordstore.util.extensions.viewBinding + +/** + * Displays the repository's git commits in git-log fashion. + * + * It provides basic information about each commit by way of a non-interactive RecyclerView. + */ +class GitLogActivity : BaseGitActivity() { + + private val binding by viewBinding(ActivityGitLogBinding::inflate) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + createRecyclerView() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + finish() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private fun createRecyclerView() { + binding.gitLogRecyclerView.apply { + setHasFixedSize(true) + addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL)) + adapter = GitLogAdapter() + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt b/app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt new file mode 100644 index 00000000..29ffbe9b --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt @@ -0,0 +1,59 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.git.log + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import app.passwordstore.databinding.GitLogRowLayoutBinding +import app.passwordstore.util.git.GitCommit +import app.passwordstore.util.git.GitLogModel +import java.text.DateFormat +import java.util.Date +import logcat.LogPriority.ERROR +import logcat.logcat + +private fun shortHash(hash: String): String { + return hash.substring(0 until 8) +} + +private fun stringFrom(date: Date): String { + return DateFormat.getDateTimeInstance().format(date) +} + +/** @see GitLogActivity */ +class GitLogAdapter : RecyclerView.Adapter() { + + private val model = GitLogModel() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = GitLogRowLayoutBinding.inflate(inflater, parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { + val commit = model.get(position) + if (commit == null) { + logcat(ERROR) { "There is no git commit for view holder at position $position." } + return + } + viewHolder.bind(commit) + } + + override fun getItemCount() = model.size + + class ViewHolder(private val binding: GitLogRowLayoutBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(commit: GitCommit) = + with(binding) { + gitLogRowMessage.text = commit.shortMessage + gitLogRowHash.text = shortHash(commit.hash) + gitLogRowTime.text = stringFrom(commit.time) + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt new file mode 100644 index 00000000..6b72792a --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt @@ -0,0 +1,87 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.main + +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import app.passwordstore.ui.crypto.BasePgpActivity +import app.passwordstore.ui.crypto.DecryptActivity +import app.passwordstore.ui.crypto.DecryptActivityV2 +import app.passwordstore.ui.passwords.PasswordStore +import app.passwordstore.util.auth.BiometricAuthenticator +import app.passwordstore.util.auth.BiometricAuthenticator.Result +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.features.Feature +import app.passwordstore.util.features.Features +import app.passwordstore.util.settings.PreferenceKeys +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class LaunchActivity : AppCompatActivity() { + + @Inject lateinit var features: Features + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val prefs = sharedPrefs + if (prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH, false)) { + BiometricAuthenticator.authenticate(this) { result -> + when (result) { + is Result.Success -> { + startTargetActivity(false) + } + is Result.HardwareUnavailableOrDisabled -> { + prefs.edit { remove(PreferenceKeys.BIOMETRIC_AUTH) } + startTargetActivity(false) + } + is Result.Failure, + Result.Cancelled -> { + finish() + } + is Result.Retry -> {} + } + } + } else { + startTargetActivity(true) + } + } + + private fun getDecryptIntent(): Intent { + return if (features.isEnabled(Feature.EnablePGPainlessBackend)) { + Intent(this, DecryptActivityV2::class.java) + } else { + Intent(this, DecryptActivity::class.java) + } + } + + private fun startTargetActivity(noAuth: Boolean) { + val intentToStart = + if (intent.action == ACTION_DECRYPT_PASS) + getDecryptIntent().apply { + putExtra( + BasePgpActivity.EXTRA_FILE_PATH, + intent.getStringExtra(BasePgpActivity.EXTRA_FILE_PATH) + ) + putExtra( + BasePgpActivity.EXTRA_REPO_PATH, + intent.getStringExtra(BasePgpActivity.EXTRA_REPO_PATH) + ) + } + else Intent(this, PasswordStore::class.java) + startActivity(intentToStart) + + Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (noAuth) 0L else 500L) + } + + companion object { + + const val ACTION_DECRYPT_PASS = "DECRYPT_PASS" + } +} diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt b/app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt new file mode 100644 index 00000000..2fe92098 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt @@ -0,0 +1,26 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.onboarding.activity + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import app.passwordstore.R + +class OnboardingActivity : AppCompatActivity(R.layout.activity_onboarding) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + supportActionBar?.hide() + } + + override fun onBackPressed() { + if (supportFragmentManager.backStackEntryCount == 0) { + finishAffinity() + } else { + super.onBackPressed() + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt new file mode 100644 index 00000000..6702afc0 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt @@ -0,0 +1,78 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.onboarding.fragments + +import android.os.Bundle +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import androidx.fragment.app.Fragment +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.databinding.FragmentCloneBinding +import app.passwordstore.ui.git.config.GitServerConfigActivity +import app.passwordstore.util.extensions.finish +import app.passwordstore.util.extensions.performTransactionWithBackStack +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.extensions.unsafeLazy +import app.passwordstore.util.extensions.viewBinding +import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import logcat.LogPriority.ERROR +import logcat.asLog +import logcat.logcat + +class CloneFragment : Fragment(R.layout.fragment_clone) { + + private val binding by viewBinding(FragmentCloneBinding::bind) + + private val settings by unsafeLazy { requireActivity().applicationContext.sharedPrefs } + + private val cloneAction = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == AppCompatActivity.RESULT_OK) { + settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } + finish() + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.cloneRemote.setOnClickListener { cloneToHiddenDir() } + binding.createLocal.setOnClickListener { createRepository() } + } + + /** Clones a remote Git repository to the app's private directory */ + private fun cloneToHiddenDir() { + cloneAction.launch(GitServerConfigActivity.createCloneIntent(requireContext())) + } + + private fun createRepository() { + val localDir = PasswordRepository.getRepositoryDirectory() + runCatching { + check(localDir.exists() || localDir.mkdir()) { "Failed to create directory!" } + PasswordRepository.createRepository(localDir) + if (!PasswordRepository.isInitialized) { + PasswordRepository.initialize() + } + parentFragmentManager.performTransactionWithBackStack(KeySelectionFragment.newInstance()) + } + .onFailure { e -> + logcat(ERROR) { e.asLog() } + if (!localDir.delete()) { + logcat { "Failed to delete local repository: $localDir" } + } + finish() + } + } + + companion object { + + fun newInstance(): CloneFragment = CloneFragment() + } +} diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt new file mode 100644 index 00000000..2b32cb61 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt @@ -0,0 +1,74 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.onboarding.fragments + +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.databinding.FragmentKeySelectionBinding +import app.passwordstore.ui.crypto.GetKeyIdsActivity +import app.passwordstore.util.extensions.commitChange +import app.passwordstore.util.extensions.finish +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.extensions.snackbar +import app.passwordstore.util.extensions.unsafeLazy +import app.passwordstore.util.extensions.viewBinding +import app.passwordstore.util.settings.PreferenceKeys +import com.google.android.material.snackbar.Snackbar +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.msfjarvis.openpgpktx.util.OpenPgpApi + +class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) { + + private val settings by unsafeLazy { requireActivity().applicationContext.sharedPrefs } + private val binding by viewBinding(FragmentKeySelectionBinding::bind) + + private val gpgKeySelectAction = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == AppCompatActivity.RESULT_OK) { + result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds -> + lifecycleScope.launch { + withContext(Dispatchers.IO) { + val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id") + gpgIdentifierFile.writeText((keyIds + "").joinToString("\n")) + } + settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } + requireActivity() + .commitChange(getString(R.string.git_commit_gpg_id, getString(R.string.app_name))) + } + } + finish() + } else { + requireActivity() + .snackbar( + message = getString(R.string.gpg_key_select_mandatory), + length = Snackbar.LENGTH_LONG + ) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.selectKey.setOnClickListener { + gpgKeySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java)) + } + } + + companion object { + + fun newInstance() = KeySelectionFragment() + } +} diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt new file mode 100644 index 00000000..4d320ff7 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt @@ -0,0 +1,33 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.onboarding.fragments + +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.annotation.Keep +import androidx.fragment.app.Fragment +import app.passwordstore.R +import app.passwordstore.databinding.FragmentWelcomeBinding +import app.passwordstore.ui.settings.SettingsActivity +import app.passwordstore.util.extensions.performTransactionWithBackStack +import app.passwordstore.util.extensions.viewBinding + +@Keep +class WelcomeFragment : Fragment(R.layout.fragment_welcome) { + + private val binding by viewBinding(FragmentWelcomeBinding::bind) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.letsGo.setOnClickListener { + parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance()) + } + binding.settingsButton.setOnClickListener { + startActivity(Intent(requireContext(), SettingsActivity::class.java)) + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt b/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt new file mode 100644 index 00000000..9c7011a4 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt @@ -0,0 +1,382 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.passwords + +import android.content.Context +import android.content.SharedPreferences +import android.os.Bundle +import android.os.Parcelable +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.appcompat.view.ActionMode +import androidx.core.content.edit +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import app.passwordstore.R +import app.passwordstore.data.password.PasswordItem +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.databinding.PasswordRecyclerViewBinding +import app.passwordstore.injection.prefs.SettingsPreferences +import app.passwordstore.ui.adapters.PasswordItemRecyclerAdapter +import app.passwordstore.ui.dialogs.BasicBottomSheet +import app.passwordstore.ui.dialogs.ItemCreationBottomSheet +import app.passwordstore.ui.git.base.BaseGitActivity +import app.passwordstore.ui.git.config.GitServerConfigActivity +import app.passwordstore.ui.util.OnOffItemAnimator +import app.passwordstore.util.extensions.base64 +import app.passwordstore.util.extensions.getString +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.extensions.viewBinding +import app.passwordstore.util.settings.AuthMode +import app.passwordstore.util.settings.GitSettings +import app.passwordstore.util.settings.PasswordSortOrder +import app.passwordstore.util.settings.PreferenceKeys +import app.passwordstore.util.shortcuts.ShortcutHandler +import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel +import com.github.michaelbull.result.fold +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import dagger.hilt.android.AndroidEntryPoint +import java.io.File +import javax.inject.Inject +import kotlinx.coroutines.launch +import me.zhanghai.android.fastscroll.FastScrollerBuilder + +@AndroidEntryPoint +class PasswordFragment : Fragment(R.layout.password_recycler_view) { + + @Inject lateinit var gitSettings: GitSettings + @Inject lateinit var shortcutHandler: ShortcutHandler + @Inject @SettingsPreferences lateinit var prefs: SharedPreferences + private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter + private lateinit var listener: OnFragmentInteractionListener + private lateinit var settings: SharedPreferences + + private var recyclerViewStateToRestore: Parcelable? = null + private var actionMode: ActionMode? = null + private var scrollTarget: File? = null + + private val model: SearchableRepositoryViewModel by activityViewModels() + private val binding by viewBinding(PasswordRecyclerViewBinding::bind) + private val swipeResult = + registerForActivityResult(StartActivityForResult()) { + binding.swipeRefresher.isRefreshing = false + requireStore().refreshPasswordList() + } + + val currentDir: File + get() = model.currentDir.value!! + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + settings = requireContext().sharedPrefs + initializePasswordList() + binding.fab.setOnClickListener { + ItemCreationBottomSheet().show(childFragmentManager, "BOTTOM_SHEET") + } + childFragmentManager.setFragmentResultListener(ITEM_CREATION_REQUEST_KEY, viewLifecycleOwner) { + _, + bundle -> + when (bundle.getString(ACTION_KEY)) { + ACTION_FOLDER -> requireStore().createFolder() + ACTION_PASSWORD -> requireStore().createPassword() + } + } + } + + private fun initializePasswordList() { + val gitDir = File(PasswordRepository.getRepositoryDirectory(), ".git") + val hasGitDir = + gitDir.exists() && gitDir.isDirectory && (gitDir.listFiles()?.isNotEmpty() == true) + binding.swipeRefresher.setOnRefreshListener { + if (!hasGitDir) { + requireStore().refreshPasswordList() + binding.swipeRefresher.isRefreshing = false + } else if (!PasswordRepository.isGitRepo()) { + BasicBottomSheet.Builder(requireContext()) + .setMessageRes(R.string.clone_git_repo) + .setPositiveButtonClickListener(getString(R.string.clone_button)) { + swipeResult.launch(GitServerConfigActivity.createCloneIntent(requireContext())) + } + .build() + .show(requireActivity().supportFragmentManager, "NOT_A_GIT_REPO") + binding.swipeRefresher.isRefreshing = false + } else { + // When authentication is set to AuthMode.None then the only git operation we can + // run is a pull, so automatically fallback to that. + val operationId = + when (gitSettings.authMode) { + AuthMode.None -> BaseGitActivity.GitOp.PULL + else -> BaseGitActivity.GitOp.SYNC + } + requireStore().apply { + lifecycleScope.launch { + launchGitOperation(operationId) + .fold( + success = { + binding.swipeRefresher.isRefreshing = false + refreshPasswordList() + }, + failure = { err -> + promptOnErrorHandler(err) { binding.swipeRefresher.isRefreshing = false } + }, + ) + } + } + } + } + + recyclerAdapter = + PasswordItemRecyclerAdapter(lifecycleScope) + .onItemClicked { _, item -> listener.onFragmentInteraction(item) } + .onSelectionChanged { selection -> + // In order to not interfere with drag selection, we disable the + // SwipeRefreshLayout + // once an item is selected. + binding.swipeRefresher.isEnabled = selection.isEmpty + + if (actionMode == null) + actionMode = + requireStore().startSupportActionMode(actionModeCallback) ?: return@onSelectionChanged + + if (!selection.isEmpty) { + actionMode!!.title = + resources.getQuantityString( + R.plurals.delete_title, + selection.size(), + selection.size() + ) + actionMode!!.invalidate() + } else { + actionMode!!.finish() + } + } + val recyclerView = binding.passRecycler + recyclerView.apply { + addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL)) + layoutManager = LinearLayoutManager(requireContext()) + itemAnimator = OnOffItemAnimator() + adapter = recyclerAdapter + } + + FastScrollerBuilder(recyclerView).build() + recyclerAdapter.makeSelectable(recyclerView) + registerForContextMenu(recyclerView) + + val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) + model.navigateTo(File(path), pushPreviousLocation = false) + model.searchResult.observe(viewLifecycleOwner) { result -> + // Only run animations when the new list is filtered, i.e., the user submitted a search, + // and not on folder navigations since the latter leads to too many removal animations. + (recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered + recyclerAdapter.submitList(result.passwordItems) { + when { + result.isFiltered -> { + // When the result is filtered, we always scroll to the top since that is + // where + // the best fuzzy match appears. + recyclerView.scrollToPosition(0) + } + scrollTarget != null -> { + scrollTarget?.let { + recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it)) + } + scrollTarget = null + } + else -> { + // When the result is not filtered and there is a saved scroll position for + // it, + // we try to restore it. + recyclerViewStateToRestore?.let { + recyclerView.layoutManager!!.onRestoreInstanceState(it) + } + recyclerViewStateToRestore = null + } + } + } + } + } + + private val actionModeCallback = + object : ActionMode.Callback { + // Called when the action mode is created; startActionMode() was called + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + // Inflate a menu resource providing context menu items + mode.menuInflater.inflate(R.menu.context_pass, menu) + // hide the fab + animateFab(false) + return true + } + + // Called each time the action mode is shown. Always called after onCreateActionMode, + // but may be called multiple times if the mode is invalidated. + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + val selectedItems = recyclerAdapter.getSelectedItems() + menu.findItem(R.id.menu_edit_password).isVisible = + selectedItems.all { it.type == PasswordItem.TYPE_CATEGORY } + menu.findItem(R.id.menu_pin_password).isVisible = + selectedItems.size == 1 && selectedItems[0].type == PasswordItem.TYPE_PASSWORD + return true + } + + // Called when the user selects a contextual menu item + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + return when (item.itemId) { + R.id.menu_delete_password -> { + requireStore().deletePasswords(recyclerAdapter.getSelectedItems()) + // Action picked, so close the CAB + mode.finish() + true + } + R.id.menu_move_password -> { + requireStore().movePasswords(recyclerAdapter.getSelectedItems()) + false + } + R.id.menu_edit_password -> { + requireStore().renameCategory(recyclerAdapter.getSelectedItems()) + mode.finish() + false + } + R.id.menu_pin_password -> { + val passwordItem = recyclerAdapter.getSelectedItems()[0] + shortcutHandler.addPinnedShortcut( + passwordItem, + passwordItem.createAuthEnabledIntent(requireContext()) + ) + false + } + else -> false + } + } + + // Called when the user exits the action mode + override fun onDestroyActionMode(mode: ActionMode) { + recyclerAdapter.requireSelectionTracker().clearSelection() + actionMode = null + // show the fab + animateFab(true) + } + + private fun animateFab(show: Boolean) = + with(binding.fab) { + val animation = + AnimationUtils.loadAnimation(context, if (show) R.anim.scale_up else R.anim.scale_down) + animation.setAnimationListener( + object : Animation.AnimationListener { + override fun onAnimationRepeat(animation: Animation?) {} + + override fun onAnimationEnd(animation: Animation?) { + if (!show) visibility = View.GONE + } + + override fun onAnimationStart(animation: Animation?) { + if (show) visibility = View.VISIBLE + } + } + ) + animate() + .rotationBy(if (show) -90f else 90f) + .setStartDelay(if (show) 100 else 0) + .setDuration(100) + .start() + startAnimation(animation) + } + } + + override fun onResume() { + super.onResume() + binding.swipeRefresher.isEnabled = !prefs.getBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, false) + } + + override fun onAttach(context: Context) { + super.onAttach(context) + runCatching { + listener = + object : OnFragmentInteractionListener { + override fun onFragmentInteraction(item: PasswordItem) { + if ( + settings.getString(PreferenceKeys.SORT_ORDER) == + PasswordSortOrder.RECENTLY_USED.name + ) { + // save the time when password was used + val preferences = + context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) + preferences.edit { + putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString()) + } + } + + if (item.type == PasswordItem.TYPE_CATEGORY) { + navigateTo(item.file) + } else { + if (requireArguments().getBoolean("matchWith", false)) { + requireStore().matchPasswordWithApp(item) + } else { + requireStore().decryptPassword(item) + } + } + } + } + } + .onFailure { + throw ClassCastException("$context must implement OnFragmentInteractionListener") + } + } + + private fun requireStore() = requireActivity() as PasswordStore + + /** Returns true if the back press was handled by the [Fragment]. */ + fun onBackPressedInActivity(): Boolean { + if (!model.canNavigateBack) return false + // The RecyclerView state is restored when the asynchronous update operation on the + // adapter is completed. + recyclerViewStateToRestore = model.navigateBack() + if (!model.canNavigateBack) requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(false) + return true + } + + fun dismissActionMode() { + actionMode?.finish() + } + + companion object { + + const val ITEM_CREATION_REQUEST_KEY = "creation_key" + const val ACTION_KEY = "action" + const val ACTION_FOLDER = "folder" + const val ACTION_PASSWORD = "password" + + fun newInstance(args: Bundle): PasswordFragment { + val fragment = PasswordFragment() + fragment.arguments = args + return fragment + } + } + + fun navigateTo(file: File) { + requireStore().clearSearch() + model.navigateTo( + file, + recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState() + ) + requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + + fun scrollToOnNextRefresh(file: File) { + scrollTarget = file + } + + interface OnFragmentInteractionListener { + + fun onFragmentInteraction(item: PasswordItem) + } +} diff --git a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt b/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt new file mode 100644 index 00000000..cb77bdb7 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt @@ -0,0 +1,639 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.passwords + +import android.annotation.SuppressLint +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.KeyEvent +import android.view.Menu +import android.view.MenuItem +import android.view.MenuItem.OnActionExpandListener +import android.view.WindowManager +import androidx.activity.result.contract.ActivityResultContracts.RequestPermission +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.activity.viewModels +import androidx.appcompat.widget.SearchView +import androidx.appcompat.widget.SearchView.OnQueryTextListener +import androidx.core.content.edit +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.commit +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.data.password.PasswordItem +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.crypto.BasePgpActivity +import app.passwordstore.ui.crypto.BasePgpActivity.Companion.getLongName +import app.passwordstore.ui.crypto.DecryptActivity +import app.passwordstore.ui.crypto.DecryptActivityV2 +import app.passwordstore.ui.crypto.PasswordCreationActivity +import app.passwordstore.ui.crypto.PasswordCreationActivityV2 +import app.passwordstore.ui.dialogs.FolderCreationDialogFragment +import app.passwordstore.ui.folderselect.SelectFolderActivity +import app.passwordstore.ui.git.base.BaseGitActivity +import app.passwordstore.ui.onboarding.activity.OnboardingActivity +import app.passwordstore.ui.settings.SettingsActivity +import app.passwordstore.util.autofill.AutofillMatcher +import app.passwordstore.util.extensions.base64 +import app.passwordstore.util.extensions.commitChange +import app.passwordstore.util.extensions.contains +import app.passwordstore.util.extensions.getString +import app.passwordstore.util.extensions.isInsideRepository +import app.passwordstore.util.extensions.listFilesRecursively +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.features.Feature +import app.passwordstore.util.features.Features +import app.passwordstore.util.settings.AuthMode +import app.passwordstore.util.settings.PreferenceKeys +import app.passwordstore.util.shortcuts.ShortcutHandler +import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel +import com.github.michaelbull.result.fold +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import dagger.hilt.android.AndroidEntryPoint +import java.io.File +import java.lang.Character.UnicodeBlock +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import logcat.LogPriority.ERROR +import logcat.LogPriority.INFO +import logcat.logcat + +const val PASSWORD_FRAGMENT_TAG = "PasswordsList" + +@AndroidEntryPoint +class PasswordStore : BaseGitActivity() { + + @Inject lateinit var features: Features + @Inject lateinit var shortcutHandler: ShortcutHandler + private lateinit var searchItem: MenuItem + private val settings by lazy { sharedPrefs } + + private val model: SearchableRepositoryViewModel by viewModels { + ViewModelProvider.AndroidViewModelFactory(application) + } + + private val storagePermissionRequest = + registerForActivityResult(RequestPermission()) { granted -> + if (granted) checkLocalRepository() + } + + private val directorySelectAction = + registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + checkLocalRepository() + } + } + + private val listRefreshAction = + registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + refreshPasswordList() + } + } + + private val passwordMoveAction = + registerForActivityResult(StartActivityForResult()) { result -> + val intentData = result.data ?: return@registerForActivityResult + val filesToMove = requireNotNull(intentData.getStringArrayExtra("Files")) + val target = File(requireNotNull(intentData.getStringExtra("SELECTED_FOLDER_PATH"))) + val repositoryPath = PasswordRepository.getRepositoryDirectory().absolutePath + if (!target.isDirectory) { + logcat(ERROR) { "Tried moving passwords to a non-existing folder." } + return@registerForActivityResult + } + + logcat { "Moving passwords to ${intentData.getStringExtra("SELECTED_FOLDER_PATH")}" } + logcat { filesToMove.joinToString(", ") } + + lifecycleScope.launch(Dispatchers.IO) { + for (file in filesToMove) { + val source = File(file) + if (!source.exists()) { + logcat(ERROR) { "Tried moving something that appears non-existent." } + continue + } + val destinationFile = File(target.absolutePath + "/" + source.name) + val basename = source.nameWithoutExtension + val sourceLongName = getLongName(requireNotNull(source.parent), repositoryPath, basename) + val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename) + if (destinationFile.exists()) { + logcat(ERROR) { "Trying to move a file that already exists." } + withContext(Dispatchers.Main) { + MaterialAlertDialogBuilder(this@PasswordStore) + .setTitle(resources.getString(R.string.password_exists_title)) + .setMessage( + resources.getString( + R.string.password_exists_message, + destinationLongName, + sourceLongName + ) + ) + .setPositiveButton(R.string.dialog_ok) { _, _ -> + launch(Dispatchers.IO) { moveFile(source, destinationFile) } + } + .setNegativeButton(R.string.dialog_cancel, null) + .show() + } + } else { + launch(Dispatchers.IO) { moveFile(source, destinationFile) } + } + } + when (filesToMove.size) { + 1 -> { + val source = File(filesToMove[0]) + val basename = source.nameWithoutExtension + val sourceLongName = + getLongName(requireNotNull(source.parent), repositoryPath, basename) + val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename) + withContext(Dispatchers.Main) { + commitChange( + resources.getString( + R.string.git_commit_move_text, + sourceLongName, + destinationLongName + ), + ) + } + } + else -> { + val repoDir = PasswordRepository.getRepositoryDirectory().absolutePath + val relativePath = getRelativePath("${target.absolutePath}/", repoDir) + withContext(Dispatchers.Main) { + commitChange( + resources.getString(R.string.git_commit_move_multiple_text, relativePath), + ) + } + } + } + } + refreshPasswordList() + getPasswordFragment()?.dismissActionMode() + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + // open search view on search key, or Ctr+F + if ( + (keyCode == KeyEvent.KEYCODE_SEARCH || + keyCode == KeyEvent.KEYCODE_F && event.isCtrlPressed) && !searchItem.isActionViewExpanded + ) { + searchItem.expandActionView() + return true + } + + // open search view on any printable character and query for it + val c = event.unicodeChar.toChar() + val printable = isPrintable(c) + if (printable && !searchItem.isActionViewExpanded) { + searchItem.expandActionView() + (searchItem.actionView as SearchView).setQuery(c.toString(), true) + return true + } + return super.onKeyDown(keyCode, event) + } + + @SuppressLint("NewApi") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_pwdstore) + + model.currentDir.observe(this) { dir -> + val basePath = PasswordRepository.getRepositoryDirectory().absoluteFile + supportActionBar?.apply { + if (dir != basePath) title = dir.name else setTitle(R.string.app_name) + } + } + } + + override fun onStart() { + super.onStart() + refreshPasswordList() + } + + override fun onResume() { + super.onResume() + checkLocalRepository() + if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false) && ::searchItem.isInitialized) { + if (!searchItem.isActionViewExpanded) { + searchItem.expandActionView() + } + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + val menuRes = + when { + gitSettings.authMode == AuthMode.None -> R.menu.main_menu_no_auth + PasswordRepository.isGitRepo() -> R.menu.main_menu_git + else -> R.menu.main_menu_non_git + } + menuInflater.inflate(menuRes, menu) + return super.onCreateOptionsMenu(menu) + } + + override fun onPrepareOptionsMenu(menu: Menu): Boolean { + // Invalidation forces onCreateOptionsMenu to be called again. This is cheap and quick so + // we can get by without any noticeable difference in performance. + invalidateOptionsMenu() + searchItem = menu.findItem(R.id.action_search) + val searchView = searchItem.actionView as SearchView + searchView.setOnQueryTextListener( + object : OnQueryTextListener { + override fun onQueryTextSubmit(s: String): Boolean { + searchView.clearFocus() + return true + } + + override fun onQueryTextChange(s: String): Boolean { + val filter = s.trim() + // List the contents of the current directory if the user enters a blank + // search term. + if (filter.isEmpty()) + model.navigateTo(newDirectory = model.currentDir.value!!, pushPreviousLocation = false) + else model.search(filter) + return true + } + } + ) + + // When using the support library, the setOnActionExpandListener() method is + // static and accepts the MenuItem object as an argument + searchItem.setOnActionExpandListener( + object : OnActionExpandListener { + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + refreshPasswordList() + return true + } + + override fun onMenuItemActionExpand(item: MenuItem): Boolean { + return true + } + } + ) + if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false)) { + searchItem.expandActionView() + } + return super.onPrepareOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + val id = item.itemId + val initBefore = + MaterialAlertDialogBuilder(this) + .setMessage(resources.getString(R.string.creation_dialog_text)) + .setPositiveButton(resources.getString(R.string.dialog_ok), null) + when (id) { + R.id.user_pref -> { + runCatching { startActivity(Intent(this, SettingsActivity::class.java)) } + .onFailure { e -> e.printStackTrace() } + } + R.id.git_push -> { + if (!PasswordRepository.isInitialized) { + initBefore.show() + } else { + runGitOperation(GitOp.PUSH) + } + } + R.id.git_pull -> { + if (!PasswordRepository.isInitialized) { + initBefore.show() + } else { + runGitOperation(GitOp.PULL) + } + } + R.id.git_sync -> { + if (!PasswordRepository.isInitialized) { + initBefore.show() + } else { + runGitOperation(GitOp.SYNC) + } + } + R.id.refresh -> refreshPasswordList() + android.R.id.home -> onBackPressed() + else -> return super.onOptionsItemSelected(item) + } + return true + } + + override fun onBackPressed() { + if (getPasswordFragment()?.onBackPressedInActivity() != true) super.onBackPressed() + } + + private fun getPasswordFragment(): PasswordFragment? { + return supportFragmentManager.findFragmentByTag(PASSWORD_FRAGMENT_TAG) as? PasswordFragment + } + + fun clearSearch() { + if (searchItem.isActionViewExpanded) searchItem.collapseActionView() + } + + private fun runGitOperation(operation: GitOp) = + lifecycleScope.launch { + launchGitOperation(operation) + .fold( + success = { refreshPasswordList() }, + failure = { promptOnErrorHandler(it) }, + ) + } + + private fun checkLocalRepository() { + PasswordRepository.initialize() + checkLocalRepository(PasswordRepository.getRepositoryDirectory()) + } + + private fun checkLocalRepository(localDir: File?) { + if (localDir != null && settings.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) { + logcat { "Check, dir: ${localDir.absolutePath}" } + // do not push the fragment if we already have it + if ( + getPasswordFragment() == null || settings.getBoolean(PreferenceKeys.REPO_CHANGED, false) + ) { + settings.edit { putBoolean(PreferenceKeys.REPO_CHANGED, false) } + val args = Bundle() + args.putString(REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath) + + // if the activity was started from the autofill settings, the + // intent is to match a clicked pwd with app. pass this to fragment + if (intent.getBooleanExtra("matchWith", false)) { + args.putBoolean("matchWith", true) + } + supportActionBar?.apply { + show() + setDisplayHomeAsUpEnabled(false) + } + supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + supportFragmentManager.commit { + replace(R.id.main_layout, PasswordFragment.newInstance(args), PASSWORD_FRAGMENT_TAG) + } + } + } else { + startActivity(Intent(this, OnboardingActivity::class.java)) + } + } + + private fun getRelativePath(fullPath: String, repositoryPath: String): String { + return fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") + } + + fun decryptPassword(item: PasswordItem) { + val authDecryptIntent = item.createAuthEnabledIntent(this) + val decryptIntent = + (authDecryptIntent.clone() as Intent).setComponent( + ComponentName( + this, + if (features.isEnabled(Feature.EnablePGPainlessBackend)) { + DecryptActivityV2::class.java + } else { + DecryptActivity::class.java + } + ) + ) + + startActivity(decryptIntent) + + // Adds shortcut + shortcutHandler.addDynamicShortcut(item, authDecryptIntent) + } + + private fun validateState(): Boolean { + if (!PasswordRepository.isInitialized) { + MaterialAlertDialogBuilder(this) + .setMessage(resources.getString(R.string.creation_dialog_text)) + .setPositiveButton(resources.getString(R.string.dialog_ok), null) + .show() + return false + } + return true + } + + fun createPassword() { + if (!validateState()) return + val currentDir = currentDir + logcat(INFO) { "Adding file to : ${currentDir.absolutePath}" } + val creationActivity = + if (features.isEnabled(Feature.EnablePGPainlessBackend)) + PasswordCreationActivityV2::class.java + else PasswordCreationActivity::class.java + val intent = Intent(this, creationActivity) + intent.putExtra(BasePgpActivity.EXTRA_FILE_PATH, currentDir.absolutePath) + intent.putExtra( + BasePgpActivity.EXTRA_REPO_PATH, + PasswordRepository.getRepositoryDirectory().absolutePath + ) + listRefreshAction.launch(intent) + } + + fun createFolder() { + if (!validateState()) return + FolderCreationDialogFragment.newInstance(currentDir.path).show(supportFragmentManager, null) + } + + fun deletePasswords(selectedItems: List) { + var size = 0 + selectedItems.forEach { + if (it.file.isFile) size++ else size += it.file.listFilesRecursively().size + } + if (size == 0) { + selectedItems.map { item -> item.file.deleteRecursively() } + refreshPasswordList() + return + } + MaterialAlertDialogBuilder(this) + .setMessage(resources.getQuantityString(R.plurals.delete_dialog_text, size, size)) + .setPositiveButton(resources.getString(R.string.dialog_yes)) { _, _ -> + val filesToDelete = arrayListOf() + selectedItems.forEach { item -> + if (item.file.isDirectory) filesToDelete.addAll(item.file.listFilesRecursively()) + else filesToDelete.add(item.file) + } + selectedItems.map { item -> item.file.deleteRecursively() } + refreshPasswordList() + AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete) + val fmt = + selectedItems.joinToString(separator = ", ") { item -> + item.file.toRelativeString(PasswordRepository.getRepositoryDirectory()) + } + lifecycleScope.launch { + commitChange( + resources.getString(R.string.git_commit_remove_text, fmt), + ) + } + } + .setNegativeButton(resources.getString(R.string.dialog_no), null) + .show() + } + + fun movePasswords(values: List) { + val intent = Intent(this, SelectFolderActivity::class.java) + val fileLocations = values.map { it.file.absolutePath }.toTypedArray() + intent.putExtra("Files", fileLocations) + passwordMoveAction.launch(intent) + } + + enum class CategoryRenameError(val resource: Int) { + None(0), + EmptyField(R.string.message_category_error_empty_field), + CategoryExists(R.string.message_category_error_category_exists), + DestinationOutsideRepo(R.string.message_error_destination_outside_repo), + } + + /** + * Prompt the user with a new category name to assign, if the new category forms/leads a path + * (i.e. contains "/"), intermediate directories will be created and new category will be placed + * inside. + * + * @param oldCategory The category to change its name + * @param error Determines whether to show an error to the user in the alert dialog, this error + * may be due to the new category the user entered already exists or the field was empty or the + * destination path is outside the repository + * + * @see [CategoryRenameError] + * @see [isInsideRepository] + */ + private fun renameCategory( + oldCategory: PasswordItem, + error: CategoryRenameError = CategoryRenameError.None + ) { + val view = layoutInflater.inflate(R.layout.folder_dialog_fragment, null) + val newCategoryEditText = view.findViewById(R.id.folder_name_text) + + if (error != CategoryRenameError.None) { + newCategoryEditText.error = getString(error.resource) + } + + val dialog = + MaterialAlertDialogBuilder(this) + .setTitle(R.string.title_rename_folder) + .setView(view) + .setMessage(getString(R.string.message_rename_folder, oldCategory.name)) + .setPositiveButton(R.string.dialog_ok) { _, _ -> + val newCategory = File("${oldCategory.file.parent}/${newCategoryEditText.text}") + when { + newCategoryEditText.text.isNullOrBlank() -> + renameCategory(oldCategory, CategoryRenameError.EmptyField) + newCategory.exists() -> renameCategory(oldCategory, CategoryRenameError.CategoryExists) + !newCategory.isInsideRepository() -> + renameCategory(oldCategory, CategoryRenameError.DestinationOutsideRepo) + else -> + lifecycleScope.launch(Dispatchers.IO) { + moveFile(oldCategory.file, newCategory) + + // associate the new category with the last category's timestamp in + // history + val preference = + getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) + val timestamp = preference.getString(oldCategory.file.absolutePath.base64()) + if (timestamp != null) { + preference.edit { + remove(oldCategory.file.absolutePath.base64()) + putString(newCategory.absolutePath.base64(), timestamp) + } + } + + withContext(Dispatchers.Main) { + commitChange( + resources.getString( + R.string.git_commit_move_text, + oldCategory.name, + newCategory.name + ), + ) + } + } + } + } + .setNegativeButton(R.string.dialog_skip, null) + .create() + + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) + dialog.show() + } + + fun renameCategory(categories: List) { + for (oldCategory in categories) { + renameCategory(oldCategory) + } + } + + /** + * Refreshes the password list by re-executing the last navigation or search action, preserving + * the navigation stack and scroll position. If the current directory no longer exists, navigation + * is reset to the repository root. If the optional [target] argument is provided, it will be + * entered if it is a directory or scrolled into view if it is a file (both inside the current + * directory). + */ + fun refreshPasswordList(target: File? = null) { + val plist = getPasswordFragment() + if (target?.isDirectory == true && model.currentDir.value?.contains(target) == true) { + plist?.navigateTo(target) + } else if (target?.isFile == true && model.currentDir.value?.contains(target) == true) { + // Creating new passwords is handled by an activity, so we will refresh in onStart. + plist?.scrollToOnNextRefresh(target) + } else if (model.currentDir.value?.isDirectory == true) { + model.forceRefresh() + } else { + model.reset() + supportActionBar?.setDisplayHomeAsUpEnabled(false) + } + } + + private val currentDir: File + get() = getPasswordFragment()?.currentDir ?: PasswordRepository.getRepositoryDirectory() + + private suspend fun moveFile(source: File, destinationFile: File) { + val sourceDestinationMap = + if (source.isDirectory) { + destinationFile.mkdirs() + // Recursively list all files (not directories) below `source`, then + // obtain the corresponding target file by resolving the relative path + // starting at the destination folder. + source.listFilesRecursively().associateWith { + destinationFile.resolve(it.relativeTo(source)) + } + } else { + mapOf(source to destinationFile) + } + if (!source.renameTo(destinationFile)) { + logcat(ERROR) { "Something went wrong while moving $source to $destinationFile." } + withContext(Dispatchers.Main) { + MaterialAlertDialogBuilder(this@PasswordStore) + .setTitle(R.string.password_move_error_title) + .setMessage(getString(R.string.password_move_error_message, source, destinationFile)) + .setCancelable(true) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } else { + AutofillMatcher.updateMatches(this, sourceDestinationMap) + } + } + + fun matchPasswordWithApp(item: PasswordItem) { + val path = + item.file.absolutePath + .replace(PasswordRepository.getRepositoryDirectory().toString() + "/", "") + .replace(".gpg", "") + val data = Intent() + data.putExtra("path", path) + setResult(RESULT_OK, data) + finish() + } + + companion object { + + const val REQUEST_ARG_PATH = "PATH" + private fun isPrintable(c: Char): Boolean { + val block = UnicodeBlock.of(c) + return (!Character.isISOControl(c) && block != null && block !== UnicodeBlock.SPECIALS) + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt new file mode 100644 index 00000000..69b40a2e --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt @@ -0,0 +1,70 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +@file:Suppress("BlockingMethodInNonBlockingContext") + +package app.passwordstore.ui.pgp + +import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContracts.OpenDocument +import androidx.appcompat.app.AppCompatActivity +import app.passwordstore.R +import app.passwordstore.crypto.KeyUtils.tryGetId +import app.passwordstore.crypto.PGPKey +import app.passwordstore.crypto.PGPKeyManager +import com.github.michaelbull.result.mapBoth +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlinx.coroutines.runBlocking + +@AndroidEntryPoint +class PGPKeyImportActivity : AppCompatActivity() { + + @Inject lateinit var keyManager: PGPKeyManager + + private val pgpKeyImportAction = + registerForActivityResult(OpenDocument()) { uri -> + runCatching { + if (uri == null) { + return@runCatching null + } + val keyInputStream = + contentResolver.openInputStream(uri) + ?: throw IllegalStateException("Failed to open selected file") + val bytes = keyInputStream.readBytes() + val (key, error) = runBlocking { keyManager.addKey(PGPKey(bytes)) } + if (error != null) throw error + key + } + .mapBoth( + { key -> + if (key != null) { + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.pgp_key_import_succeeded)) + .setMessage(getString(R.string.pgp_key_import_succeeded_message, tryGetId(key))) + .setPositiveButton(android.R.string.ok) { _, _ -> finish() } + .setOnCancelListener { finish() } + .show() + } else { + finish() + } + }, + { throwable -> + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.pgp_key_import_failed)) + .setMessage(throwable.message) + .setPositiveButton(android.R.string.ok) { _, _ -> finish() } + .setOnCancelListener { finish() } + .show() + } + ) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + pgpKeyImportAction.launch(arrayOf("*/*")) + } +} diff --git a/app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt b/app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt new file mode 100644 index 00000000..9243a7f5 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt @@ -0,0 +1,94 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.proxy + +import android.content.SharedPreferences +import android.net.InetAddresses +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Patterns +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import androidx.core.os.postDelayed +import androidx.core.widget.doOnTextChanged +import app.passwordstore.R +import app.passwordstore.databinding.ActivityProxySelectorBinding +import app.passwordstore.injection.prefs.ProxyPreferences +import app.passwordstore.util.extensions.getString +import app.passwordstore.util.extensions.viewBinding +import app.passwordstore.util.proxy.ProxyUtils +import app.passwordstore.util.settings.GitSettings +import app.passwordstore.util.settings.PreferenceKeys +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +private val WEB_ADDRESS_REGEX = Patterns.WEB_URL.toRegex() + +@AndroidEntryPoint +class ProxySelectorActivity : AppCompatActivity() { + + @Inject lateinit var gitSettings: GitSettings + @ProxyPreferences @Inject lateinit var proxyPrefs: SharedPreferences + @Inject lateinit var proxyUtils: ProxyUtils + private val binding by viewBinding(ActivityProxySelectorBinding::inflate) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + with(binding) { + proxyHost.setText(proxyPrefs.getString(PreferenceKeys.PROXY_HOST)) + proxyUser.setText(proxyPrefs.getString(PreferenceKeys.PROXY_USERNAME)) + proxyPrefs + .getInt(PreferenceKeys.PROXY_PORT, -1) + .takeIf { it != -1 } + ?.let { proxyPort.setText("$it") } + proxyPassword.setText(proxyPrefs.getString(PreferenceKeys.PROXY_PASSWORD)) + save.setOnClickListener { saveSettings() } + proxyHost.doOnTextChanged { text, _, _, _ -> + if (text != null) { + proxyHost.error = + if (isNumericAddress(text) || text.matches(WEB_ADDRESS_REGEX)) { + null + } else { + getString(R.string.invalid_proxy_url) + } + } + } + } + } + + private fun isNumericAddress(text: CharSequence): Boolean { + return if (Build.VERSION.SDK_INT >= 29) { + InetAddresses.isNumericAddress(text as String) + } else { + @Suppress("DEPRECATION") Patterns.IP_ADDRESS.matcher(text).matches() + } + } + + private fun saveSettings() { + proxyPrefs.edit { + binding.proxyHost.text + ?.toString() + ?.takeIf { it.isNotEmpty() } + .let { gitSettings.proxyHost = it } + binding.proxyUser.text + ?.toString() + ?.takeIf { it.isNotEmpty() } + .let { gitSettings.proxyUsername = it } + binding.proxyPort.text + ?.toString() + ?.takeIf { it.isNotEmpty() } + ?.let { gitSettings.proxyPort = it.toInt() } + binding.proxyPassword.text + ?.toString() + ?.takeIf { it.isNotEmpty() } + .let { gitSettings.proxyPassword = it } + } + proxyUtils.setDefaultProxy() + Handler(Looper.getMainLooper()).postDelayed(500) { finish() } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt new file mode 100644 index 00000000..64ca531b --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt @@ -0,0 +1,133 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import android.annotation.SuppressLint +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.annotation.RequiresApi +import androidx.appcompat.widget.AppCompatTextView +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import app.passwordstore.BuildConfig +import app.passwordstore.R +import app.passwordstore.util.autofill.DirectoryStructure +import app.passwordstore.util.extensions.autofillManager +import app.passwordstore.util.settings.PreferenceKeys +import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel +import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import de.Maxr1998.modernpreferences.PreferenceScreen +import de.Maxr1998.modernpreferences.helpers.editText +import de.Maxr1998.modernpreferences.helpers.onClick +import de.Maxr1998.modernpreferences.helpers.singleChoice +import de.Maxr1998.modernpreferences.helpers.switch +import de.Maxr1998.modernpreferences.preferences.SwitchPreference +import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem + +class AutofillSettings(private val activity: FragmentActivity) : SettingsProvider { + + private val isAutofillServiceEnabled: Boolean + get() { + if (Build.VERSION.SDK_INT < 26) return false + return activity.autofillManager?.hasEnabledAutofillServices() == true + } + + @RequiresApi(26) + private fun showAutofillDialog(pref: SwitchPreference) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + pref.checked = isAutofillServiceEnabled + } + else -> {} + } + } + MaterialAlertDialogBuilder(activity).run { + setTitle(R.string.pref_autofill_enable_title) + @SuppressLint("InflateParams") + val layout = activity.layoutInflater.inflate(R.layout.oreo_autofill_instructions, null) + val supportedBrowsersTextView = layout.findViewById(R.id.supportedBrowsers) + supportedBrowsersTextView.text = + getInstalledBrowsersWithAutofillSupportLevel(context).joinToString(separator = "\n") { + val appLabel = it.first + val supportDescription = + when (it.second) { + BrowserAutofillSupportLevel.None -> + activity.getString(R.string.oreo_autofill_no_support) + BrowserAutofillSupportLevel.FlakyFill -> + activity.getString(R.string.oreo_autofill_flaky_fill_support) + BrowserAutofillSupportLevel.PasswordFill -> + activity.getString(R.string.oreo_autofill_password_fill_support) + BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility -> + activity.getString( + R.string.oreo_autofill_password_fill_and_conditional_save_support + ) + BrowserAutofillSupportLevel.GeneralFill -> + activity.getString(R.string.oreo_autofill_general_fill_support) + BrowserAutofillSupportLevel.GeneralFillAndSave -> + activity.getString(R.string.oreo_autofill_general_fill_and_save_support) + } + "$appLabel: $supportDescription" + } + setView(layout) + setPositiveButton(R.string.dialog_ok) { _, _ -> + val intent = + Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply { + data = Uri.parse("package:${BuildConfig.APPLICATION_ID}") + } + activity.startActivity(intent) + } + setNegativeButton(R.string.dialog_cancel, null) + setOnDismissListener { pref.checked = isAutofillServiceEnabled } + activity.lifecycle.addObserver(observer) + show() + } + } + + override fun provideSettings(builder: PreferenceScreen.Builder) { + builder.apply { + switch(PreferenceKeys.AUTOFILL_ENABLE) { + titleRes = R.string.pref_autofill_enable_title + visible = Build.VERSION.SDK_INT >= 26 + defaultValue = isAutofillServiceEnabled + onClick { + if (Build.VERSION.SDK_INT < 26) return@onClick true + if (isAutofillServiceEnabled) { + activity.autofillManager?.disableAutofillServices() + } else { + showAutofillDialog(this) + } + false + } + } + val values = + activity.resources.getStringArray(R.array.oreo_autofill_directory_structure_values) + val titles = + activity.resources.getStringArray(R.array.oreo_autofill_directory_structure_entries) + val items = values.zip(titles).map { SelectionItem(it.first, it.second, null) } + singleChoice(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE, items) { + initialSelection = DirectoryStructure.DEFAULT.value + dependency = PreferenceKeys.AUTOFILL_ENABLE + titleRes = R.string.oreo_autofill_preference_directory_structure + } + editText(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) { + dependency = PreferenceKeys.AUTOFILL_ENABLE + titleRes = R.string.preference_default_username_title + summaryProvider = { activity.getString(R.string.preference_default_username_summary) } + } + editText(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) { + dependency = PreferenceKeys.AUTOFILL_ENABLE + titleRes = R.string.preference_custom_public_suffixes_title + summaryProvider = { activity.getString(R.string.preference_custom_public_suffixes_summary) } + textInputHintRes = R.string.preference_custom_public_suffixes_hint + } + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt new file mode 100644 index 00000000..d6520ea3 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt @@ -0,0 +1,110 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import android.content.pm.ShortcutManager +import android.os.Build +import androidx.core.content.edit +import androidx.core.content.getSystemService +import androidx.fragment.app.FragmentActivity +import app.passwordstore.R +import app.passwordstore.util.auth.BiometricAuthenticator +import app.passwordstore.util.auth.BiometricAuthenticator.Result +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.settings.PreferenceKeys +import de.Maxr1998.modernpreferences.PreferenceScreen +import de.Maxr1998.modernpreferences.helpers.checkBox +import de.Maxr1998.modernpreferences.helpers.onClick +import de.Maxr1998.modernpreferences.helpers.singleChoice +import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem + +class GeneralSettings(private val activity: FragmentActivity) : SettingsProvider { + + override fun provideSettings(builder: PreferenceScreen.Builder) { + builder.apply { + val themeValues = activity.resources.getStringArray(R.array.app_theme_values) + val themeOptions = activity.resources.getStringArray(R.array.app_theme_options) + val themeItems = + themeValues.zip(themeOptions).map { SelectionItem(it.first, it.second, null) } + singleChoice(PreferenceKeys.APP_THEME, themeItems) { + initialSelection = activity.resources.getString(R.string.app_theme_def) + titleRes = R.string.pref_app_theme_title + } + + val sortValues = activity.resources.getStringArray(R.array.sort_order_values) + val sortOptions = activity.resources.getStringArray(R.array.sort_order_entries) + val sortItems = sortValues.zip(sortOptions).map { SelectionItem(it.first, it.second, null) } + singleChoice(PreferenceKeys.SORT_ORDER, sortItems) { + initialSelection = sortValues[0] + titleRes = R.string.pref_sort_order_title + } + + checkBox(PreferenceKeys.DISABLE_SYNC_ACTION) { + titleRes = R.string.pref_disable_sync_on_pull_title + summaryRes = R.string.pref_disable_sync_on_pull_summary + defaultValue = false + } + + checkBox(PreferenceKeys.FILTER_RECURSIVELY) { + titleRes = R.string.pref_recursive_filter_title + summaryRes = R.string.pref_recursive_filter_summary + defaultValue = true + } + + checkBox(PreferenceKeys.SEARCH_ON_START) { + titleRes = R.string.pref_search_on_start_title + summaryRes = R.string.pref_search_on_start_summary + defaultValue = false + } + + checkBox(PreferenceKeys.SHOW_HIDDEN_CONTENTS) { + titleRes = R.string.pref_show_hidden_title + summaryRes = R.string.pref_show_hidden_summary + defaultValue = false + } + + val canAuthenticate = BiometricAuthenticator.canAuthenticate(activity) + checkBox(PreferenceKeys.BIOMETRIC_AUTH) { + titleRes = R.string.pref_biometric_auth_title + defaultValue = false + enabled = canAuthenticate + summaryRes = + if (canAuthenticate) R.string.pref_biometric_auth_summary + else R.string.pref_biometric_auth_summary_error + onClick { + enabled = false + val isChecked = checked + activity.sharedPrefs.edit { + BiometricAuthenticator.authenticate(activity) { result -> + when (result) { + is Result.Success -> { + // Apply the changes + putBoolean(PreferenceKeys.BIOMETRIC_AUTH, checked) + enabled = true + } + is Result.Retry -> {} + else -> { + // If any error occurs, revert back to the previous + // state. This + // catch-all clause includes the cancellation case. + putBoolean(PreferenceKeys.BIOMETRIC_AUTH, !checked) + checked = !isChecked + enabled = true + } + } + } + } + if (Build.VERSION.SDK_INT >= 25) { + activity.getSystemService()?.apply { + removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList()) + } + } + false + } + } + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/MiscSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/MiscSettings.kt new file mode 100644 index 00000000..7bd04eb5 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/MiscSettings.kt @@ -0,0 +1,81 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.activity.result.contract.ActivityResultContracts +import androidx.documentfile.provider.DocumentFile +import androidx.fragment.app.FragmentActivity +import app.passwordstore.BuildConfig +import app.passwordstore.R +import app.passwordstore.util.services.PasswordExportService +import app.passwordstore.util.settings.PreferenceKeys +import de.Maxr1998.modernpreferences.PreferenceScreen +import de.Maxr1998.modernpreferences.helpers.checkBox +import de.Maxr1998.modernpreferences.helpers.onClick +import de.Maxr1998.modernpreferences.helpers.pref + +class MiscSettings(activity: FragmentActivity) : SettingsProvider { + + private val storeExportAction = + activity.registerForActivityResult( + object : ActivityResultContracts.OpenDocumentTree() { + override fun createIntent(context: Context, input: Uri?): Intent { + return super.createIntent(context, input).apply { + flags = + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or + Intent.FLAG_GRANT_PREFIX_URI_PERMISSION + } + } + } + ) { uri: Uri? -> + if (uri == null) return@registerForActivityResult + val targetDirectory = DocumentFile.fromTreeUri(activity.applicationContext, uri) + + if (targetDirectory != null) { + val service = + Intent(activity.applicationContext, PasswordExportService::class.java).apply { + action = PasswordExportService.ACTION_EXPORT_PASSWORD + putExtra("uri", uri) + } + + if (Build.VERSION.SDK_INT >= 26) { + activity.startForegroundService(service) + } else { + activity.startService(service) + } + } + } + + override fun provideSettings(builder: PreferenceScreen.Builder) { + builder.apply { + pref(PreferenceKeys.EXPORT_PASSWORDS) { + titleRes = R.string.prefs_export_passwords_title + summaryRes = R.string.prefs_export_passwords_summary + onClick { + storeExportAction.launch(null) + true + } + } + checkBox(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY) { + defaultValue = false + titleRes = R.string.pref_clear_clipboard_title + summaryRes = R.string.pref_clear_clipboard_summary + } + checkBox(PreferenceKeys.ENABLE_DEBUG_LOGGING) { + defaultValue = false + titleRes = R.string.pref_debug_logging_title + summaryRes = R.string.pref_debug_logging_summary + visible = !BuildConfig.DEBUG + } + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt new file mode 100644 index 00000000..b59e17ba --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt @@ -0,0 +1,37 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import androidx.fragment.app.FragmentActivity +import app.passwordstore.ui.pgp.PGPKeyImportActivity +import app.passwordstore.util.extensions.launchActivity +import app.passwordstore.util.features.Feature +import de.Maxr1998.modernpreferences.PreferenceScreen +import de.Maxr1998.modernpreferences.helpers.checkBox +import de.Maxr1998.modernpreferences.helpers.onClick +import de.Maxr1998.modernpreferences.helpers.pref + +class PGPSettings(private val activity: FragmentActivity) : SettingsProvider { + + override fun provideSettings(builder: PreferenceScreen.Builder) { + builder.apply { + val enablePGPainless = + checkBox(Feature.EnablePGPainlessBackend.configKey) { + title = "Enable new PGP backend" + persistent = true + } + pref("_") { + title = "Import PGP key" + persistent = false + dependency = enablePGPainless.key + onClick { + activity.launchActivity(PGPKeyImportActivity::class.java) + false + } + } + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt new file mode 100644 index 00000000..f90a8d95 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt @@ -0,0 +1,53 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import android.text.InputType +import androidx.fragment.app.FragmentActivity +import app.passwordstore.R +import app.passwordstore.util.settings.PreferenceKeys +import de.Maxr1998.modernpreferences.PreferenceScreen +import de.Maxr1998.modernpreferences.helpers.checkBox +import de.Maxr1998.modernpreferences.helpers.editText +import de.Maxr1998.modernpreferences.helpers.onSelectionChange +import de.Maxr1998.modernpreferences.helpers.singleChoice +import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem + +class PasswordSettings(private val activity: FragmentActivity) : SettingsProvider { + + override fun provideSettings(builder: PreferenceScreen.Builder) { + builder.apply { + val values = activity.resources.getStringArray(R.array.pwgen_provider_values) + val labels = activity.resources.getStringArray(R.array.pwgen_provider_labels) + val items = values.zip(labels).map { SelectionItem(it.first, it.second, null) } + singleChoice( + PreferenceKeys.PREF_KEY_PWGEN_TYPE, + items, + ) { + initialSelection = "classic" + titleRes = R.string.pref_password_generator_type_title + onSelectionChange { true } + } + editText(PreferenceKeys.GENERAL_SHOW_TIME) { + titleRes = R.string.pref_clipboard_timeout_title + summaryProvider = { timeout -> + activity.getString(R.string.pref_clipboard_timeout_summary, timeout ?: "45") + } + textInputType = InputType.TYPE_CLASS_NUMBER + } + checkBox(PreferenceKeys.SHOW_PASSWORD) { + titleRes = R.string.show_password_pref_title + summaryRes = R.string.show_password_pref_summary + defaultValue = true + } + checkBox(PreferenceKeys.COPY_ON_DECRYPT) { + titleRes = R.string.pref_copy_title + summaryRes = R.string.pref_copy_summary + defaultValue = false + } + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt b/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt new file mode 100644 index 00000000..c9e28123 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt @@ -0,0 +1,194 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.ShortcutManager +import android.os.Build +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.edit +import androidx.core.content.getSystemService +import androidx.fragment.app.FragmentActivity +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.injection.prefs.GitPreferences +import app.passwordstore.ui.git.config.GitConfigActivity +import app.passwordstore.ui.git.config.GitServerConfigActivity +import app.passwordstore.ui.proxy.ProxySelectorActivity +import app.passwordstore.ui.sshkeygen.ShowSshKeyFragment +import app.passwordstore.ui.sshkeygen.SshKeyGenActivity +import app.passwordstore.ui.sshkeygen.SshKeyImportActivity +import app.passwordstore.util.extensions.getString +import app.passwordstore.util.extensions.launchActivity +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.extensions.snackbar +import app.passwordstore.util.extensions.unsafeLazy +import app.passwordstore.util.git.sshj.SshKey +import app.passwordstore.util.settings.GitSettings +import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import de.Maxr1998.modernpreferences.Preference +import de.Maxr1998.modernpreferences.PreferenceScreen +import de.Maxr1998.modernpreferences.helpers.checkBox +import de.Maxr1998.modernpreferences.helpers.onClick +import de.Maxr1998.modernpreferences.helpers.pref + +class RepositorySettings(private val activity: FragmentActivity) : SettingsProvider { + + private val generateSshKey = + activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + showSshKeyPref?.visible = SshKey.canShowSshPublicKey + } + + private val hiltEntryPoint by unsafeLazy { + EntryPointAccessors.fromApplication( + activity.applicationContext, + RepositorySettingsEntryPoint::class.java, + ) + } + + private var showSshKeyPref: Preference? = null + + override fun provideSettings(builder: PreferenceScreen.Builder) { + val encryptedPreferences = hiltEntryPoint.encryptedPreferences() + val gitSettings = hiltEntryPoint.gitSettings() + + builder.apply { + checkBox(PreferenceKeys.REBASE_ON_PULL) { + titleRes = R.string.pref_rebase_on_pull_title + summaryRes = R.string.pref_rebase_on_pull_summary + summaryOnRes = R.string.pref_rebase_on_pull_summary_on + defaultValue = true + } + pref(PreferenceKeys.GIT_SERVER_INFO) { + titleRes = R.string.pref_edit_git_server_settings + visible = PasswordRepository.isGitRepo() + onClick { + activity.launchActivity(GitServerConfigActivity::class.java) + true + } + } + pref(PreferenceKeys.PROXY_SETTINGS) { + titleRes = R.string.pref_edit_proxy_settings + visible = gitSettings.url?.startsWith("https") == true && PasswordRepository.isGitRepo() + onClick { + activity.launchActivity(ProxySelectorActivity::class.java) + true + } + } + pref(PreferenceKeys.GIT_CONFIG) { + titleRes = R.string.pref_edit_git_config + visible = PasswordRepository.isGitRepo() + onClick { + activity.launchActivity(GitConfigActivity::class.java) + true + } + } + pref(PreferenceKeys.SSH_KEY) { + titleRes = R.string.pref_import_ssh_key_title + visible = PasswordRepository.isGitRepo() + onClick { + activity.launchActivity(SshKeyImportActivity::class.java) + true + } + } + pref(PreferenceKeys.SSH_KEYGEN) { + titleRes = R.string.pref_ssh_keygen_title + onClick { + generateSshKey.launch(Intent(activity, SshKeyGenActivity::class.java)) + true + } + } + showSshKeyPref = + pref(PreferenceKeys.SSH_SEE_KEY) { + titleRes = R.string.pref_ssh_see_key_title + visible = PasswordRepository.isGitRepo() && SshKey.canShowSshPublicKey + onClick { + ShowSshKeyFragment().show(activity.supportFragmentManager, "public_key") + true + } + } + pref(PreferenceKeys.CLEAR_SAVED_PASS) { + fun Preference.updatePref() { + val sshPass = encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) + val httpsPass = encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD) + if (sshPass == null && httpsPass == null) { + visible = false + return + } + when { + httpsPass != null -> titleRes = R.string.clear_saved_passphrase_https + sshPass != null -> titleRes = R.string.clear_saved_passphrase_ssh + } + visible = true + requestRebind() + } + onClick { + updatePref() + true + } + updatePref() + } + pref(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID) { + titleRes = R.string.pref_title_openkeystore_clear_keyid + visible = + activity.sharedPrefs.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty() + ?: false + onClick { + activity.sharedPrefs.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) } + visible = false + true + } + } + pref(PreferenceKeys.GIT_DELETE_REPO) { + titleRes = R.string.pref_git_delete_repo_title + summaryRes = R.string.pref_git_delete_repo_summary + onClick { + val repoDir = PasswordRepository.getRepositoryDirectory() + MaterialAlertDialogBuilder(activity) + .setTitle(R.string.pref_dialog_delete_title) + .setMessage(activity.getString(R.string.dialog_delete_msg, repoDir)) + .setCancelable(false) + .setPositiveButton(R.string.dialog_delete) { dialogInterface, _ -> + runCatching { + PasswordRepository.getRepositoryDirectory().deleteRecursively() + PasswordRepository.closeRepository() + } + .onFailure { it.message?.let { message -> activity.snackbar(message = message) } } + + if (Build.VERSION.SDK_INT >= 25) { + activity.getSystemService()?.apply { + removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList()) + } + } + activity.sharedPrefs.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) } + dialogInterface.cancel() + activity.finish() + } + .setNegativeButton(R.string.dialog_do_not_delete) { dialogInterface, _ -> + run { dialogInterface.cancel() } + } + .show() + true + } + } + } + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface RepositorySettingsEntryPoint { + fun gitSettings(): GitSettings + @GitPreferences fun encryptedPreferences(): SharedPreferences + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt new file mode 100644 index 00000000..8eae0520 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt @@ -0,0 +1,106 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import android.os.Bundle +import android.view.MenuItem +import androidx.appcompat.app.AppCompatActivity +import app.passwordstore.R +import app.passwordstore.databinding.ActivityPreferenceRecyclerviewBinding +import app.passwordstore.util.extensions.viewBinding +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import de.Maxr1998.modernpreferences.Preference +import de.Maxr1998.modernpreferences.PreferencesAdapter +import de.Maxr1998.modernpreferences.helpers.screen +import de.Maxr1998.modernpreferences.helpers.subScreen + +class SettingsActivity : AppCompatActivity() { + + private val miscSettings = MiscSettings(this) + private val autofillSettings = AutofillSettings(this) + private val passwordSettings = PasswordSettings(this) + private val repositorySettings = RepositorySettings(this) + private val generalSettings = GeneralSettings(this) + private val pgpSettings = PGPSettings(this) + + private val binding by viewBinding(ActivityPreferenceRecyclerviewBinding::inflate) + private val preferencesAdapter: PreferencesAdapter + get() = binding.preferenceRecyclerView.adapter as PreferencesAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + Preference.Config.dialogBuilderFactory = { context -> MaterialAlertDialogBuilder(context) } + val screen = + screen(this) { + subScreen { + titleRes = R.string.pref_category_general_title + iconRes = R.drawable.app_settings_alt_24px + generalSettings.provideSettings(this) + } + subScreen { + titleRes = R.string.pref_category_autofill_title + iconRes = R.drawable.ic_wysiwyg_24px + autofillSettings.provideSettings(this) + } + subScreen { + titleRes = R.string.pref_category_passwords_title + iconRes = R.drawable.ic_password_24px + passwordSettings.provideSettings(this) + } + subScreen { + titleRes = R.string.pref_category_repository_title + iconRes = R.drawable.ic_call_merge_24px + repositorySettings.provideSettings(this) + } + subScreen { + titleRes = R.string.pref_category_misc_title + iconRes = R.drawable.ic_miscellaneous_services_24px + miscSettings.provideSettings(this) + } + subScreen { + titleRes = R.string.pref_category_pgp_title + iconRes = R.drawable.ic_lock_open_24px + pgpSettings.provideSettings(this) + } + } + val adapter = PreferencesAdapter(screen) + adapter.onScreenChangeListener = + PreferencesAdapter.OnScreenChangeListener { subScreen, entering -> + supportActionBar?.title = + if (!entering) { + getString(R.string.action_settings) + } else { + getString(subScreen.titleRes) + } + } + savedInstanceState + ?.getParcelable("adapter") + ?.let(adapter::loadSavedState) + binding.preferenceRecyclerView.adapter = adapter + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putParcelable("adapter", preferencesAdapter.getSavedState()) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> + if (!preferencesAdapter.goBack()) { + super.onOptionsItemSelected(item) + } else { + true + } + else -> super.onOptionsItemSelected(item) + } + } + + override fun onBackPressed() { + if (!preferencesAdapter.goBack()) super.onBackPressed() + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsProvider.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsProvider.kt new file mode 100644 index 00000000..6c1f80ec --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsProvider.kt @@ -0,0 +1,15 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import de.Maxr1998.modernpreferences.PreferenceScreen + +/** Used to generate a uniform API for all settings UI classes. */ +interface SettingsProvider { + + /** Inserts the settings items for the class into the given [builder]. */ + fun provideSettings(builder: PreferenceScreen.Builder) +} diff --git a/app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt b/app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt new file mode 100644 index 00000000..a42d6aa1 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt @@ -0,0 +1,39 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.sshkeygen + +import android.app.Dialog +import android.content.Intent +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import app.passwordstore.R +import app.passwordstore.util.git.sshj.SshKey +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +class ShowSshKeyFragment : DialogFragment() { + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val activity = requireActivity() + val publicKey = SshKey.sshPublicKey + return MaterialAlertDialogBuilder(requireActivity()).run { + setMessage(getString(R.string.ssh_keygen_message, publicKey)) + setTitle(R.string.your_public_key) + setNegativeButton(R.string.ssh_keygen_later) { _, _ -> + (activity as? SshKeyGenActivity)?.finish() + } + setPositiveButton(R.string.ssh_keygen_share) { _, _ -> + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, publicKey) + } + startActivity(Intent.createChooser(sendIntent, null)) + (activity as? SshKeyGenActivity)?.finish() + } + create() + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt b/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt new file mode 100644 index 00000000..8a3d3edf --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt @@ -0,0 +1,172 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.sshkeygen + +import android.content.SharedPreferences +import android.os.Bundle +import android.security.keystore.UserNotAuthenticatedException +import android.view.MenuItem +import android.view.View +import android.view.inputmethod.InputMethodManager +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import androidx.core.content.getSystemService +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.databinding.ActivitySshKeygenBinding +import app.passwordstore.injection.prefs.GitPreferences +import app.passwordstore.util.auth.BiometricAuthenticator +import app.passwordstore.util.auth.BiometricAuthenticator.Result +import app.passwordstore.util.extensions.keyguardManager +import app.passwordstore.util.extensions.viewBinding +import app.passwordstore.util.git.sshj.SshKey +import com.github.michaelbull.result.fold +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private enum class KeyGenType(val generateKey: suspend (requireAuthentication: Boolean) -> Unit) { + Rsa({ requireAuthentication -> + SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication) + }), + Ecdsa({ requireAuthentication -> + SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication) + }), + Ed25519({ requireAuthentication -> + SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication) + }), +} + +@AndroidEntryPoint +class SshKeyGenActivity : AppCompatActivity() { + + private var keyGenType = KeyGenType.Ecdsa + private val binding by viewBinding(ActivitySshKeygenBinding::inflate) + @GitPreferences @Inject lateinit var gitPrefs: SharedPreferences + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + with(binding) { + generate.setOnClickListener { + if (SshKey.exists) { + MaterialAlertDialogBuilder(this@SshKeyGenActivity).run { + setTitle(R.string.ssh_keygen_existing_title) + setMessage(R.string.ssh_keygen_existing_message) + setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> + lifecycleScope.launch { generate() } + } + setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> + setResult(RESULT_CANCELED) + finish() + } + show() + } + } else { + lifecycleScope.launch { generate() } + } + } + keyTypeGroup.check(R.id.key_type_ecdsa) + keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa) + keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked -> + if (isChecked) { + keyGenType = + when (checkedId) { + R.id.key_type_ed25519 -> KeyGenType.Ed25519 + R.id.key_type_ecdsa -> KeyGenType.Ecdsa + R.id.key_type_rsa -> KeyGenType.Rsa + else -> throw IllegalStateException("Impossible key type selection") + } + keyTypeExplanation.setText( + when (keyGenType) { + KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519 + KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa + KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa + } + ) + } + } + keyRequireAuthentication.isEnabled = keyguardManager.isDeviceSecure + keyRequireAuthentication.isChecked = keyRequireAuthentication.isEnabled + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + onBackPressed() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private suspend fun generate() { + binding.generate.apply { + text = getString(R.string.ssh_key_gen_generating_progress) + isEnabled = false + } + binding.generate.text = getString(R.string.ssh_key_gen_generating_progress) + val result = runCatching { + withContext(Dispatchers.IO) { + val requireAuthentication = binding.keyRequireAuthentication.isChecked + if (requireAuthentication) { + val result = + withContext(Dispatchers.Main) { + suspendCoroutine { cont -> + BiometricAuthenticator.authenticate( + this@SshKeyGenActivity, + R.string.biometric_prompt_title_ssh_keygen + ) { result -> + // Do not cancel on failed attempts as these are handled by the + // authenticator UI. + if (result !is Result.Retry) cont.resume(result) + } + } + } + if (result !is Result.Success) + throw UserNotAuthenticatedException(getString(R.string.biometric_auth_generic_failure)) + } + keyGenType.generateKey(requireAuthentication) + } + } + gitPrefs.edit { remove("ssh_key_local_passphrase") } + binding.generate.apply { + text = getString(R.string.ssh_keygen_generate) + isEnabled = true + } + result.fold( + success = { ShowSshKeyFragment().show(supportFragmentManager, "public_key") }, + failure = { e -> + e.printStackTrace() + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.error_generate_ssh_key)) + .setMessage(getString(R.string.ssh_key_error_dialog_text) + e.message) + .setPositiveButton(getString(R.string.dialog_ok)) { _, _ -> + setResult(RESULT_OK) + finish() + } + .show() + }, + ) + hideKeyboard() + } + + private fun hideKeyboard() { + val imm = getSystemService() ?: return + var view = currentFocus + if (view == null) { + view = View(this) + } + imm.hideSoftInputFromWindow(view.windowToken, 0) + } +} diff --git a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt b/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt new file mode 100644 index 00000000..99b3bf3f --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt @@ -0,0 +1,66 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.sshkeygen + +import android.net.Uri +import android.os.Bundle +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import app.passwordstore.R +import app.passwordstore.util.git.sshj.SshKey +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +class SshKeyImportActivity : AppCompatActivity() { + + private val sshKeyImportAction = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> + if (uri == null) { + finish() + return@registerForActivityResult + } + runCatching { + SshKey.import(uri) + Toast.makeText( + this, + resources.getString(R.string.ssh_key_success_dialog_title), + Toast.LENGTH_LONG + ) + .show() + setResult(RESULT_OK) + finish() + } + .onFailure { e -> + MaterialAlertDialogBuilder(this) + .setTitle(resources.getString(R.string.ssh_key_error_dialog_title)) + .setMessage(e.message) + .setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> finish() } + .show() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (SshKey.exists) { + MaterialAlertDialogBuilder(this).run { + setTitle(R.string.ssh_keygen_existing_title) + setMessage(R.string.ssh_keygen_existing_message) + setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> importSshKey() } + setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() } + setOnCancelListener { finish() } + show() + } + } else { + importSshKey() + } + } + + private fun importSshKey() { + sshKeyImportAction.launch(arrayOf("*/*")) + } +} diff --git a/app/src/main/java/app/passwordstore/ui/util/OnOffItemAnimator.kt b/app/src/main/java/app/passwordstore/ui/util/OnOffItemAnimator.kt new file mode 100644 index 00000000..c315766a --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/util/OnOffItemAnimator.kt @@ -0,0 +1,71 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.util + +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.RecyclerView + +class OnOffItemAnimator : DefaultItemAnimator() { + + var isEnabled: Boolean = true + set(value) { + // Defer update until no animation is running anymore. + isRunning { field = value } + } + + private fun dontAnimate(viewHolder: RecyclerView.ViewHolder): Boolean { + dispatchAnimationFinished(viewHolder) + return false + } + + override fun animateAppearance( + viewHolder: RecyclerView.ViewHolder, + preLayoutInfo: ItemHolderInfo?, + postLayoutInfo: ItemHolderInfo + ): Boolean { + return if (isEnabled) { + super.animateAppearance(viewHolder, preLayoutInfo, postLayoutInfo) + } else { + dontAnimate(viewHolder) + } + } + + override fun animateChange( + oldHolder: RecyclerView.ViewHolder, + newHolder: RecyclerView.ViewHolder, + preInfo: ItemHolderInfo, + postInfo: ItemHolderInfo + ): Boolean { + return if (isEnabled) { + super.animateChange(oldHolder, newHolder, preInfo, postInfo) + } else { + dontAnimate(oldHolder) + } + } + + override fun animateDisappearance( + viewHolder: RecyclerView.ViewHolder, + preLayoutInfo: ItemHolderInfo, + postLayoutInfo: ItemHolderInfo? + ): Boolean { + return if (isEnabled) { + super.animateDisappearance(viewHolder, preLayoutInfo, postLayoutInfo) + } else { + dontAnimate(viewHolder) + } + } + + override fun animatePersistence( + viewHolder: RecyclerView.ViewHolder, + preInfo: ItemHolderInfo, + postInfo: ItemHolderInfo + ): Boolean { + return if (isEnabled) { + super.animatePersistence(viewHolder, preInfo, postInfo) + } else { + dontAnimate(viewHolder) + } + } +} diff --git a/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt b/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt new file mode 100644 index 00000000..e9d7c3a4 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt @@ -0,0 +1,131 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.auth + +import android.app.KeyguardManager +import androidx.annotation.StringRes +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.fragment.app.FragmentActivity +import app.passwordstore.R +import logcat.logcat + +object BiometricAuthenticator { + + private const val TAG = "BiometricAuthenticator" + private const val validAuthenticators = + Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK + + /** + * Sealed class to wrap [BiometricPrompt]'s [Int]-based return codes into more easily-interpreted + * types. + */ + sealed class Result { + + /** Biometric authentication was a success. */ + data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result() + + /** Biometric authentication has irreversibly failed. */ + data class Failure(val code: Int?, val message: CharSequence) : Result() + + /** + * An incorrect biometric was entered, but the prompt UI is offering the option to retry the + * operation. + */ + object Retry : Result() + + /** The biometric hardware is unavailable or disabled on a software or hardware level. */ + object HardwareUnavailableOrDisabled : Result() + + /** The prompt was dismissed. */ + object Cancelled : Result() + } + + fun canAuthenticate(activity: FragmentActivity): Boolean { + return BiometricManager.from(activity).canAuthenticate(validAuthenticators) == + BiometricManager.BIOMETRIC_SUCCESS + } + + fun authenticate( + activity: FragmentActivity, + @StringRes dialogTitleRes: Int = R.string.biometric_prompt_title, + callback: (Result) -> Unit + ) { + val authCallback = + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + logcat(TAG) { "BiometricAuthentication error: errorCode=$errorCode, msg=$errString" } + callback( + when (errorCode) { + BiometricPrompt.ERROR_CANCELED, + BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_NEGATIVE_BUTTON -> { + Result.Cancelled + } + BiometricPrompt.ERROR_HW_NOT_PRESENT, + BiometricPrompt.ERROR_HW_UNAVAILABLE, + BiometricPrompt.ERROR_NO_BIOMETRICS, + BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> { + Result.HardwareUnavailableOrDisabled + } + BiometricPrompt.ERROR_LOCKOUT, + BiometricPrompt.ERROR_LOCKOUT_PERMANENT, + BiometricPrompt.ERROR_NO_SPACE, + BiometricPrompt.ERROR_TIMEOUT, + BiometricPrompt.ERROR_VENDOR -> { + Result.Failure( + errorCode, + activity.getString(R.string.biometric_auth_error_reason, errString) + ) + } + BiometricPrompt.ERROR_UNABLE_TO_PROCESS -> { + Result.Retry + } + // We cover all guaranteed values above, but [errorCode] is still an Int + // at the end of + // the day so a + // catch-all else will always be required. + else -> { + Result.Failure( + errorCode, + activity.getString(R.string.biometric_auth_error_reason, errString) + ) + } + } + ) + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + callback(Result.Retry) + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + callback(Result.Success(result.cryptoObject)) + } + } + val deviceHasKeyguard = activity.getSystemService()?.isDeviceSecure == true + if (canAuthenticate(activity) || deviceHasKeyguard) { + val promptInfo = + BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(dialogTitleRes)) + .setAllowedAuthenticators(validAuthenticators) + .build() + BiometricPrompt( + activity, + ContextCompat.getMainExecutor(activity.applicationContext), + authCallback + ) + .authenticate(promptInfo) + } else { + callback(Result.HardwareUnavailableOrDisabled) + } + } +} diff --git a/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt b/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt new file mode 100644 index 00000000..6d1f2e33 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt @@ -0,0 +1,236 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.autofill + +import android.content.Context +import android.content.IntentSender +import android.service.autofill.Dataset +import android.service.autofill.FillCallback +import android.service.autofill.FillResponse +import android.service.autofill.SaveInfo +import android.view.inputmethod.InlineSuggestionsRequest +import android.widget.inline.InlinePresentationSpec +import androidx.annotation.RequiresApi +import app.passwordstore.autofill.oreo.ui.AutofillSmsActivity +import app.passwordstore.ui.autofill.AutofillDecryptActivity +import app.passwordstore.ui.autofill.AutofillDecryptActivityV2 +import app.passwordstore.ui.autofill.AutofillFilterView +import app.passwordstore.ui.autofill.AutofillPublisherChangedActivity +import app.passwordstore.ui.autofill.AutofillSaveActivity +import app.passwordstore.util.features.Feature +import app.passwordstore.util.features.Features +import com.github.androidpasswordstore.autofillparser.AutofillAction +import com.github.androidpasswordstore.autofillparser.FillableForm +import com.github.androidpasswordstore.autofillparser.fillWith +import com.github.michaelbull.result.fold +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import java.io.File +import logcat.LogPriority.ERROR +import logcat.asLog +import logcat.logcat + +/** Implements [AutofillResponseBuilder]'s methods for API 30 and above */ +@RequiresApi(30) +class Api30AutofillResponseBuilder +@AssistedInject +constructor( + @Assisted form: FillableForm, + private val features: Features, +) { + + @AssistedFactory + interface Factory { + fun create(form: FillableForm): Api30AutofillResponseBuilder + } + + private val formOrigin = form.formOrigin + private val scenario = form.scenario + private val ignoredIds = form.ignoredIds + private val saveFlags = form.saveFlags + private val clientState = form.toClientState() + + // We do not offer save when the only relevant field is a username field or there is no field. + private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave + private val canBeSaved = saveFlags != null && scenarioSupportsSave + + private fun makeIntentDataset( + context: Context, + action: AutofillAction, + intentSender: IntentSender, + metadata: DatasetMetadata, + imeSpec: InlinePresentationSpec?, + ): Dataset { + return Dataset.Builder(makeRemoteView(context, metadata)).run { + fillWith(scenario, action, credentials = null) + setAuthentication(intentSender) + if (imeSpec != null) { + val inlinePresentation = makeInlinePresentation(context, imeSpec, metadata) + if (inlinePresentation != null) { + setInlinePresentation(inlinePresentation) + } + } + build() + } + } + + private fun makeMatchDataset( + context: Context, + file: File, + imeSpec: InlinePresentationSpec? + ): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null + val metadata = makeFillMatchMetadata(context, file) + val intentSender = + if (features.isEnabled(Feature.EnablePGPainlessBackend)) { + AutofillDecryptActivityV2.makeDecryptFileIntentSender(file, context) + } else { + AutofillDecryptActivity.makeDecryptFileIntentSender(file, context) + } + return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec) + } + + private fun makeSearchDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null + val metadata = makeSearchAndFillMetadata(context) + val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin) + return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata, imeSpec) + } + + private fun makeGenerateDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null + val metadata = makeGenerateAndFillMetadata(context) + val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin) + return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata, imeSpec) + } + + private fun makeFillOtpFromSmsDataset( + context: Context, + imeSpec: InlinePresentationSpec? + ): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null + if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null + val metadata = makeFillOtpFromSmsMetadata(context) + val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context) + return makeIntentDataset( + context, + AutofillAction.FillOtpFromSms, + intentSender, + metadata, + imeSpec + ) + } + + private fun makePublisherChangedDataset( + context: Context, + publisherChangedException: AutofillPublisherChangedException, + imeSpec: InlinePresentationSpec? + ): Dataset { + val metadata = makeWarningMetadata(context) + // If the user decides to trust the new publisher, they can choose reset the list of + // matches. In this case we need to immediately show a new `FillResponse` as if the app were + // autofilled for the first time. This `FillResponse` needs to be returned as a result from + // `AutofillPublisherChangedActivity`, which is why we create and pass it on here. + val fillResponseAfterReset = makeFillResponse(context, null, emptyList()) + val intentSender = + AutofillPublisherChangedActivity.makePublisherChangedIntentSender( + context, + publisherChangedException, + fillResponseAfterReset + ) + return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec) + } + + private fun makePublisherChangedResponse( + context: Context, + inlineSuggestionsRequest: InlineSuggestionsRequest?, + publisherChangedException: AutofillPublisherChangedException + ): FillResponse { + val imeSpec = inlineSuggestionsRequest?.inlinePresentationSpecs?.firstOrNull() + return FillResponse.Builder().run { + addDataset(makePublisherChangedDataset(context, publisherChangedException, imeSpec)) + setIgnoredIds(*ignoredIds.toTypedArray()) + build() + } + } + + private fun makeFillResponse( + context: Context, + inlineSuggestionsRequest: InlineSuggestionsRequest?, + matchedFiles: List + ): FillResponse? { + var datasetCount = 0 + val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList() + return FillResponse.Builder().run { + for (file in matchedFiles) { + makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let { + datasetCount++ + addDataset(it) + } + } + makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let { + datasetCount++ + addDataset(it) + } + makeFillOtpFromSmsDataset(context, imeSpecs.getOrNull(datasetCount))?.let { + datasetCount++ + addDataset(it) + } + makeSearchDataset(context, imeSpecs.getOrNull(datasetCount))?.let { + datasetCount++ + addDataset(it) + } + if (datasetCount == 0) return null + setHeader( + makeRemoteView( + context, + makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)) + ) + ) + makeSaveInfo()?.let { setSaveInfo(it) } + setClientState(clientState) + setIgnoredIds(*ignoredIds.toTypedArray()) + build() + } + } + + // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE + // See: + // https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE + private fun makeSaveInfo(): SaveInfo? { + if (!canBeSaved) return null + check(saveFlags != null) + val idsToSave = scenario.fieldsToSave.toTypedArray() + if (idsToSave.isEmpty()) return null + var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD + if (scenario.hasUsername) { + saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME + } + return SaveInfo.Builder(saveDataTypes, idsToSave).run { + setFlags(saveFlags) + build() + } + } + + /** Creates and returns a suitable [FillResponse] to the Autofill framework. */ + fun fillCredentials( + context: Context, + inlineSuggestionsRequest: InlineSuggestionsRequest?, + callback: FillCallback + ) { + AutofillMatcher.getMatchesFor(context, formOrigin) + .fold( + success = { matchedFiles -> + callback.onSuccess(makeFillResponse(context, inlineSuggestionsRequest, matchedFiles)) + }, + failure = { e -> + logcat(ERROR) { e.asLog() } + callback.onSuccess(makePublisherChangedResponse(context, inlineSuggestionsRequest, e)) + } + ) + } +} diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt new file mode 100644 index 00000000..4fbccd87 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt @@ -0,0 +1,202 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.autofill + +import android.content.Context +import android.content.SharedPreferences +import android.widget.Toast +import androidx.core.content.edit +import app.passwordstore.R +import com.github.androidpasswordstore.autofillparser.FormOrigin +import com.github.androidpasswordstore.autofillparser.computeCertificatesHash +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import java.io.File +import logcat.LogPriority.ERROR +import logcat.LogPriority.WARN +import logcat.logcat + +private const val PREFERENCES_AUTOFILL_APP_MATCHES = "oreo_autofill_app_matches" +private val Context.autofillAppMatches + get() = getSharedPreferences(PREFERENCES_AUTOFILL_APP_MATCHES, Context.MODE_PRIVATE) + +private const val PREFERENCES_AUTOFILL_WEB_MATCHES = "oreo_autofill_web_matches" +private val Context.autofillWebMatches + get() = getSharedPreferences(PREFERENCES_AUTOFILL_WEB_MATCHES, Context.MODE_PRIVATE) + +private fun Context.matchPreferences(formOrigin: FormOrigin): SharedPreferences { + return when (formOrigin) { + is FormOrigin.App -> autofillAppMatches + is FormOrigin.Web -> autofillWebMatches + } +} + +class AutofillPublisherChangedException(val formOrigin: FormOrigin) : + Exception( + "The publisher of '${formOrigin.identifier}' changed since an entry was first matched with this app" + ) { + + init { + require(formOrigin is FormOrigin.App) + } +} + +/** Manages "matches", i.e., associations between apps or websites and Password Store entries. */ +class AutofillMatcher { + + companion object { + + private const val MAX_NUM_MATCHES = 10 + + private const val PREFERENCE_PREFIX_TOKEN = "token;" + private fun tokenKey(formOrigin: FormOrigin.App) = + "$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}" + + private const val PREFERENCE_PREFIX_MATCHES = "matches;" + private fun matchesKey(formOrigin: FormOrigin) = + "$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}" + + private fun hasFormOriginHashChanged(context: Context, formOrigin: FormOrigin): Boolean { + return when (formOrigin) { + is FormOrigin.Web -> false + is FormOrigin.App -> { + val packageName = formOrigin.identifier + val certificatesHash = computeCertificatesHash(context, packageName) + val storedCertificatesHash = + context.autofillAppMatches.getString(tokenKey(formOrigin), null) ?: return false + val hashHasChanged = certificatesHash != storedCertificatesHash + if (hashHasChanged) { + logcat(ERROR) { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" } + true + } else { + false + } + } + } + } + + private fun storeFormOriginHash(context: Context, formOrigin: FormOrigin) { + if (formOrigin is FormOrigin.App) { + val packageName = formOrigin.identifier + val certificatesHash = computeCertificatesHash(context, packageName) + context.autofillAppMatches.edit { putString(tokenKey(formOrigin), certificatesHash) } + } + // We don't need to store a hash for FormOrigin.Web since it can only originate from + // browsers we trust to verify the origin. + } + + /** + * Get all Password Store entries that have already been associated with [formOrigin] by the + * user. + * + * If [formOrigin] represents an app and that app's certificates have changed since the first + * time the user associated an entry with it, an [AutofillPublisherChangedException] will be + * thrown. + */ + fun getMatchesFor( + context: Context, + formOrigin: FormOrigin + ): Result, AutofillPublisherChangedException> { + if (hasFormOriginHashChanged(context, formOrigin)) { + return Err(AutofillPublisherChangedException(formOrigin)) + } + val matchPreferences = context.matchPreferences(formOrigin) + val matchedFiles = + matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) } + return Ok( + matchedFiles + .filter { it.exists() } + .also { validFiles -> + matchPreferences.edit { + putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet()) + } + } + ) + } + + fun clearMatchesFor(context: Context, formOrigin: FormOrigin) { + context.matchPreferences(formOrigin).edit { + remove(matchesKey(formOrigin)) + if (formOrigin is FormOrigin.App) remove(tokenKey(formOrigin)) + } + } + + /** + * Associates the store entry [file] with [formOrigin], such that future Autofill responses to + * requests from this app or website offer this entry as a dataset. + * + * The maximum number of matches is limited by [MAX_NUM_MATCHES] since older versions of Android + * may crash when too many datasets are offered. + */ + fun addMatchFor(context: Context, formOrigin: FormOrigin, file: File) { + if (!file.exists()) return + if (hasFormOriginHashChanged(context, formOrigin)) { + // This should never happen since we already verified the publisher in + // getMatchesFor. + logcat(ERROR) { "App publisher changed between getMatchesFor and addMatchFor" } + throw AutofillPublisherChangedException(formOrigin) + } + val matchPreferences = context.matchPreferences(formOrigin) + val matchedFiles = + matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) } + val newFiles = setOf(file.absoluteFile).union(matchedFiles) + if (newFiles.size > MAX_NUM_MATCHES) { + Toast.makeText( + context, + context.getString(R.string.oreo_autofill_max_matches_reached, MAX_NUM_MATCHES), + Toast.LENGTH_LONG + ) + .show() + return + } + matchPreferences.edit { + putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet()) + } + storeFormOriginHash(context, formOrigin) + logcat { "Stored match for $formOrigin" } + } + + /** + * Goes through all existing matches and updates their associated entries by using [moveFromTo] + * as a lookup table and deleting the matches for files in [delete]. + */ + fun updateMatches( + context: Context, + moveFromTo: Map = emptyMap(), + delete: Collection = emptyList() + ) { + val deletePathList = delete.map { it.absolutePath } + val oldNewPathMap = + moveFromTo.mapValues { it.value.absolutePath }.mapKeys { it.key.absolutePath } + for (prefs in listOf(context.autofillAppMatches, context.autofillWebMatches)) { + for ((key, value) in prefs.all) { + if (!key.startsWith(PREFERENCE_PREFIX_MATCHES)) continue + // We know that preferences starting with `PREFERENCE_PREFIX_MATCHES` were + // created with `putStringSet`. + @Suppress("UNCHECKED_CAST") val oldMatches = value as? Set + if (oldMatches == null) { + logcat(WARN) { "Failed to read matches for $key" } + continue + } + // Delete all matches for file locations that are going to be overwritten, then + // transfer matches over to the files at their new locations. + val newMatches = + oldMatches + .asSequence() + .minus(deletePathList) + .minus(oldNewPathMap.values) + .map { match -> + val newPath = oldNewPathMap[match] ?: return@map match + logcat { "Updating match for $key: $match --> $newPath" } + newPath + } + .toSet() + if (newMatches != oldMatches) prefs.edit { putStringSet(key, newMatches) } + } + } + } + } +} diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillPreferences.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillPreferences.kt new file mode 100644 index 00000000..d99ba7a2 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillPreferences.kt @@ -0,0 +1,148 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.autofill + +import android.content.Context +import androidx.annotation.RequiresApi +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.util.extensions.getString +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.services.getDefaultUsername +import app.passwordstore.util.settings.PreferenceKeys +import com.github.androidpasswordstore.autofillparser.Credentials +import java.io.File +import java.nio.file.Paths +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +enum class DirectoryStructure(val value: String) { + EncryptedUsername("encrypted_username"), + FileBased("file"), + DirectoryBased("directory"); + + /** + * Returns the username associated to [file], following the convention of the current + * [DirectoryStructure]. + * + * Examples: + * - * --> null (EncryptedUsername) + * - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased) + * - work/example.org/john@doe.org/password.gpg --> john@doe.org (DirectoryBased) + * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback) + */ + fun getUsernameFor(file: File): String? = + when (this) { + EncryptedUsername -> null + FileBased -> file.nameWithoutExtension + DirectoryBased -> file.parentFile?.name ?: file.nameWithoutExtension + } + + /** + * Returns the origin identifier associated to [file], following the convention of the current + * [DirectoryStructure]. + * + * At least one of [DirectoryStructure.getIdentifierFor] and + * [DirectoryStructure.getAccountPartFor] will always return a non-null result. + * + * Examples: + * - work/example.org.gpg --> example.org (EncryptedUsername) + * - work/example.org/john@doe.org.gpg --> example.org (FileBased) + * - example.org.gpg --> example.org (FileBased, fallback) + * - work/example.org/john@doe.org/password.gpg --> example.org (DirectoryBased) + * - Temporary PIN.gpg --> null (DirectoryBased) + */ + fun getIdentifierFor(file: File): String? = + when (this) { + EncryptedUsername -> file.nameWithoutExtension + FileBased -> file.parentFile?.name ?: file.nameWithoutExtension + DirectoryBased -> file.parentFile?.parent + } + + /** + * Returns the path components of [file] until right before the component that contains the origin + * identifier according to the current [DirectoryStructure]. + * + * Examples: + * - work/example.org.gpg --> work (EncryptedUsername) + * - work/example.org/john@doe.org.gpg --> work (FileBased) + * - example.org/john@doe.org.gpg --> null (FileBased) + * - john@doe.org.gpg --> null (FileBased) + * - work/example.org/john@doe.org/password.gpg --> work (DirectoryBased) + * - example.org/john@doe.org/password.gpg --> null (DirectoryBased) + */ + fun getPathToIdentifierFor(file: File): String? = + when (this) { + EncryptedUsername -> file.parent + FileBased -> file.parentFile?.parent + DirectoryBased -> file.parentFile?.parentFile?.parent + } + + /** + * Returns the path component of [file] following the origin identifier according to the current + * [DirectoryStructure](without file extension). + * + * At least one of [DirectoryStructure.getIdentifierFor] and + * [DirectoryStructure.getAccountPartFor] will always return a non-null result. + * + * Examples: + * - * --> null (EncryptedUsername) + * - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased) + * - example.org.gpg --> null (FileBased, fallback) + * - work/example.org/john@doe.org/password.gpg --> john@doe.org/password (DirectoryBased) + * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback) + */ + fun getAccountPartFor(file: File): String? = + when (this) { + EncryptedUsername -> null + FileBased -> file.nameWithoutExtension.takeIf { file.parentFile != null } + DirectoryBased -> + file.parentFile?.let { parentFile -> "${parentFile.name}/${file.nameWithoutExtension}" } + ?: file.nameWithoutExtension + } + + @RequiresApi(26) + fun getSaveFolderName(sanitizedIdentifier: String, username: String?) = + when (this) { + EncryptedUsername -> "/" + FileBased -> sanitizedIdentifier + DirectoryBased -> Paths.get(sanitizedIdentifier, username ?: "username").toString() + } + + fun getSaveFileName(username: String?, identifier: String) = + when (this) { + EncryptedUsername -> identifier + FileBased -> username + DirectoryBased -> "password" + } + + companion object { + + val DEFAULT = FileBased + + private val reverseMap = values().associateBy { it.value } + fun fromValue(value: String?) = if (value != null) reverseMap[value] ?: DEFAULT else DEFAULT + } +} + +object AutofillPreferences { + + fun directoryStructure(context: Context): DirectoryStructure { + val value = context.sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE) + return DirectoryStructure.fromValue(value) + } + + fun credentialsFromStoreEntry( + context: Context, + file: File, + entry: PasswordEntry, + directoryStructure: DirectoryStructure + ): Credentials { + // Always give priority to a username stored in the encrypted extras + val username = + entry.username ?: directoryStructure.getUsernameFor(file) ?: context.getDefaultUsername() + val totp = if (entry.hasTotp()) runBlocking { entry.totp.first().value } else null + return Credentials(username, entry.password, totp) + } +} diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillResponseBuilder.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillResponseBuilder.kt new file mode 100644 index 00000000..de542bac --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillResponseBuilder.kt @@ -0,0 +1,233 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.autofill + +import android.content.Context +import android.content.IntentSender +import android.os.Build +import android.os.Bundle +import android.service.autofill.Dataset +import android.service.autofill.FillCallback +import android.service.autofill.FillResponse +import android.service.autofill.SaveInfo +import androidx.annotation.RequiresApi +import app.passwordstore.autofill.oreo.ui.AutofillSmsActivity +import app.passwordstore.ui.autofill.AutofillDecryptActivity +import app.passwordstore.ui.autofill.AutofillDecryptActivityV2 +import app.passwordstore.ui.autofill.AutofillFilterView +import app.passwordstore.ui.autofill.AutofillPublisherChangedActivity +import app.passwordstore.ui.autofill.AutofillSaveActivity +import app.passwordstore.util.features.Feature +import app.passwordstore.util.features.Features +import com.github.androidpasswordstore.autofillparser.AutofillAction +import com.github.androidpasswordstore.autofillparser.AutofillScenario +import com.github.androidpasswordstore.autofillparser.Credentials +import com.github.androidpasswordstore.autofillparser.FillableForm +import com.github.androidpasswordstore.autofillparser.fillWith +import com.github.michaelbull.result.fold +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import java.io.File +import logcat.LogPriority.ERROR +import logcat.asLog +import logcat.logcat + +@RequiresApi(26) +class AutofillResponseBuilder +@AssistedInject +constructor( + @Assisted form: FillableForm, + private val features: Features, +) { + + @AssistedFactory + interface Factory { + fun create(form: FillableForm): AutofillResponseBuilder + } + + private val formOrigin = form.formOrigin + private val scenario = form.scenario + private val ignoredIds = form.ignoredIds + private val saveFlags = form.saveFlags + private val clientState = form.toClientState() + + // We do not offer save when the only relevant field is a username field or there is no field. + private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave + private val canBeSaved = saveFlags != null && scenarioSupportsSave + + private fun makeIntentDataset( + context: Context, + action: AutofillAction, + intentSender: IntentSender, + metadata: DatasetMetadata, + ): Dataset { + return Dataset.Builder(makeRemoteView(context, metadata)).run { + fillWith(scenario, action, credentials = null) + setAuthentication(intentSender) + build() + } + } + + private fun makeMatchDataset(context: Context, file: File): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null + val metadata = makeFillMatchMetadata(context, file) + val intentSender = + if (features.isEnabled(Feature.EnablePGPainlessBackend)) { + AutofillDecryptActivityV2.makeDecryptFileIntentSender(file, context) + } else { + AutofillDecryptActivity.makeDecryptFileIntentSender(file, context) + } + return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata) + } + + private fun makeSearchDataset(context: Context): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null + val metadata = makeSearchAndFillMetadata(context) + val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin) + return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata) + } + + private fun makeGenerateDataset(context: Context): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null + val metadata = makeGenerateAndFillMetadata(context) + val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin) + return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata) + } + + private fun makeFillOtpFromSmsDataset(context: Context): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null + if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null + val metadata = makeFillOtpFromSmsMetadata(context) + val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context) + return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata) + } + + private fun makePublisherChangedDataset( + context: Context, + publisherChangedException: AutofillPublisherChangedException, + ): Dataset { + val metadata = makeWarningMetadata(context) + // If the user decides to trust the new publisher, they can choose reset the list of + // matches. In this case we need to immediately show a new `FillResponse` as if the app were + // autofilled for the first time. This `FillResponse` needs to be returned as a result from + // `AutofillPublisherChangedActivity`, which is why we create and pass it on here. + val fillResponseAfterReset = makeFillResponse(context, emptyList()) + val intentSender = + AutofillPublisherChangedActivity.makePublisherChangedIntentSender( + context, + publisherChangedException, + fillResponseAfterReset + ) + return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata) + } + + private fun makePublisherChangedResponse( + context: Context, + publisherChangedException: AutofillPublisherChangedException + ): FillResponse { + return FillResponse.Builder().run { + addDataset(makePublisherChangedDataset(context, publisherChangedException)) + setIgnoredIds(*ignoredIds.toTypedArray()) + build() + } + } + + // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE + // See: + // https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE + private fun makeSaveInfo(): SaveInfo? { + if (!canBeSaved) return null + check(saveFlags != null) + val idsToSave = scenario.fieldsToSave.toTypedArray() + if (idsToSave.isEmpty()) return null + var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD + if (scenario.hasUsername) { + saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME + } + return SaveInfo.Builder(saveDataTypes, idsToSave).run { + setFlags(saveFlags) + build() + } + } + + private fun makeFillResponse(context: Context, matchedFiles: List): FillResponse? { + var datasetCount = 0 + return FillResponse.Builder().run { + for (file in matchedFiles) { + makeMatchDataset(context, file)?.let { + datasetCount++ + addDataset(it) + } + } + makeGenerateDataset(context)?.let { + datasetCount++ + addDataset(it) + } + makeFillOtpFromSmsDataset(context)?.let { + datasetCount++ + addDataset(it) + } + makeSearchDataset(context)?.let { + datasetCount++ + addDataset(it) + } + if (datasetCount == 0) return null + if (Build.VERSION.SDK_INT >= 28) { + setHeader( + makeRemoteView( + context, + makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)) + ) + ) + } + makeSaveInfo()?.let { setSaveInfo(it) } + setClientState(clientState) + setIgnoredIds(*ignoredIds.toTypedArray()) + build() + } + } + + /** Creates and returns a suitable [FillResponse] to the Autofill framework. */ + fun fillCredentials(context: Context, callback: FillCallback) { + AutofillMatcher.getMatchesFor(context, formOrigin) + .fold( + success = { matchedFiles -> callback.onSuccess(makeFillResponse(context, matchedFiles)) }, + failure = { e -> + logcat(ERROR) { e.asLog() } + callback.onSuccess(makePublisherChangedResponse(context, e)) + } + ) + } + + companion object { + + fun makeFillInDataset( + context: Context, + credentials: Credentials, + clientState: Bundle, + action: AutofillAction + ): Dataset { + val scenario = AutofillScenario.fromClientState(clientState) + // Before Android P, Datasets used for fill-in had to come with a RemoteViews, even + // though they are rarely shown. + // FIXME: We should clone the original dataset here and add the credentials to be filled + // in. Otherwise, the entry in the cached list of datasets will be overwritten by the + // fill-in dataset without any visual representation. This causes it to be missing from + // the Autofill suggestions shown after the user clears the filled out form fields. + val builder = + if (Build.VERSION.SDK_INT >= 28) { + Dataset.Builder() + } else { + Dataset.Builder(makeRemoteView(context, makeEmptyMetadata())) + } + return builder.run { + if (scenario != null) fillWith(scenario, action, credentials) + else logcat(ERROR) { "Failed to recover scenario from client state" } + build() + } + } + } +} diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt new file mode 100644 index 00000000..9bf2cc8d --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt @@ -0,0 +1,120 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.autofill + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.res.Resources +import android.graphics.drawable.Icon +import android.os.Build +import android.service.autofill.InlinePresentation +import android.view.View +import android.widget.RemoteViews +import android.widget.inline.InlinePresentationSpec +import androidx.annotation.DrawableRes +import androidx.autofill.inline.UiVersions +import androidx.autofill.inline.v1.InlineSuggestionUi +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.passwords.PasswordStore +import java.io.File + +data class DatasetMetadata(val title: String, val subtitle: String?, @DrawableRes val iconRes: Int) + +fun makeRemoteView(context: Context, metadata: DatasetMetadata): RemoteViews { + return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply { + setTextViewText(R.id.title, metadata.title) + if (metadata.subtitle != null) { + setTextViewText(R.id.summary, metadata.subtitle) + } else { + setViewVisibility(R.id.summary, View.GONE) + } + if (metadata.iconRes != Resources.ID_NULL) { + setImageViewResource(R.id.icon, metadata.iconRes) + } else { + setViewVisibility(R.id.icon, View.GONE) + } + } +} + +@SuppressLint("RestrictedApi") +fun makeInlinePresentation( + context: Context, + imeSpec: InlinePresentationSpec, + metadata: DatasetMetadata +): InlinePresentation? { + if (Build.VERSION.SDK_INT < 30) return null + + if (UiVersions.INLINE_UI_VERSION_1 !in UiVersions.getVersions(imeSpec.style)) return null + + val launchIntent = + PendingIntent.getActivity( + context, + 0, + Intent(context, PasswordStore::class.java), + if (Build.VERSION.SDK_INT >= 31) { + PendingIntent.FLAG_MUTABLE + } else { + 0 + }, + ) + val slice = + InlineSuggestionUi.newContentBuilder(launchIntent).run { + setTitle(metadata.title) + if (metadata.subtitle != null) setSubtitle(metadata.subtitle) + setContentDescription( + if (metadata.subtitle != null) "${metadata.title} - ${metadata.subtitle}" + else metadata.title + ) + setStartIcon(Icon.createWithResource(context, metadata.iconRes)) + build().slice + } + + return InlinePresentation(slice, imeSpec, false) +} + +fun makeFillMatchMetadata(context: Context, file: File): DatasetMetadata { + val directoryStructure = AutofillPreferences.directoryStructure(context) + val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory()) + val title = + directoryStructure.getIdentifierFor(relativeFile) + ?: directoryStructure.getAccountPartFor(relativeFile)!! + val subtitle = directoryStructure.getAccountPartFor(relativeFile) + return DatasetMetadata(title, subtitle, R.drawable.ic_person_black_24dp) +} + +fun makeSearchAndFillMetadata(context: Context) = + DatasetMetadata( + context.getString(R.string.oreo_autofill_search_in_store), + null, + R.drawable.ic_search_black_24dp + ) + +fun makeGenerateAndFillMetadata(context: Context) = + DatasetMetadata( + context.getString(R.string.oreo_autofill_generate_password), + null, + R.drawable.ic_autofill_new_password + ) + +fun makeFillOtpFromSmsMetadata(context: Context) = + DatasetMetadata( + context.getString(R.string.oreo_autofill_fill_otp_from_sms), + null, + R.drawable.ic_autofill_sms + ) + +fun makeEmptyMetadata() = DatasetMetadata("PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher) + +fun makeWarningMetadata(context: Context) = + DatasetMetadata( + context.getString(R.string.oreo_autofill_warning_publisher_dataset_title), + context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary), + R.drawable.ic_warning_red_24dp + ) + +fun makeHeaderMetadata(title: String) = DatasetMetadata(title, null, 0) diff --git a/app/src/main/java/app/passwordstore/util/crypto/GpgIdentifier.kt b/app/src/main/java/app/passwordstore/util/crypto/GpgIdentifier.kt new file mode 100644 index 00000000..3df3e09f --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/crypto/GpgIdentifier.kt @@ -0,0 +1,42 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.crypto + +import me.msfjarvis.openpgpktx.util.OpenPgpUtils + +sealed class GpgIdentifier { + data class KeyId(val id: Long) : GpgIdentifier() + data class UserId(val email: String) : GpgIdentifier() + + companion object { + @OptIn(ExperimentalUnsignedTypes::class) + fun fromString(identifier: String): GpgIdentifier? { + if (identifier.isEmpty()) return null + // Match long key IDs: + // FF22334455667788 or 0xFF22334455667788 + val maybeLongKeyId = + identifier.removePrefix("0x").takeIf { it.matches("[a-fA-F0-9]{16}".toRegex()) } + if (maybeLongKeyId != null) { + val keyId = maybeLongKeyId.toULong(16) + return KeyId(keyId.toLong()) + } + + // Match fingerprints: + // FF223344556677889900112233445566778899 or 0xFF223344556677889900112233445566778899 + val maybeFingerprint = + identifier.removePrefix("0x").takeIf { it.matches("[a-fA-F0-9]{40}".toRegex()) } + if (maybeFingerprint != null) { + // Truncating to the long key ID is not a security issue since OpenKeychain only + // accepts + // non-ambiguous key IDs. + val keyId = maybeFingerprint.takeLast(16).toULong(16) + return KeyId(keyId.toLong()) + } + + return OpenPgpUtils.splitUserId(identifier).email?.let { UserId(it) } + } + } +} diff --git a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt new file mode 100644 index 00000000..69811e05 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt @@ -0,0 +1,133 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.extensions + +import android.app.KeyguardManager +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.util.Base64 +import android.util.TypedValue +import android.view.View +import android.view.autofill.AutofillManager +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.fragment.app.FragmentActivity +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import app.passwordstore.BuildConfig +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.util.git.operation.GitOperation +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.google.android.material.snackbar.Snackbar +import logcat.logcat + +/** Get an instance of [AutofillManager]. Only available on Android Oreo and above */ +val Context.autofillManager: AutofillManager? + @RequiresApi(26) get() = getSystemService() + +/** Get an instance of [ClipboardManager] */ +val Context.clipboard + get() = getSystemService() + +/** Wrapper for [getEncryptedPrefs] to avoid open-coding the file name at each call site */ +fun Context.getEncryptedGitPrefs() = getEncryptedPrefs("git_operation") + +/** Get an instance of [EncryptedSharedPreferences] with the given [fileName] */ +private fun Context.getEncryptedPrefs(fileName: String): SharedPreferences { + val masterKeyAlias = + MasterKey.Builder(applicationContext).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() + return EncryptedSharedPreferences.create( + applicationContext, + fileName, + masterKeyAlias, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) +} + +/** Get an instance of [KeyguardManager] */ +val Context.keyguardManager: KeyguardManager + get() = getSystemService()!! + +/** Get the default [SharedPreferences] instance */ +val Context.sharedPrefs: SharedPreferences + get() = getSharedPreferences("${BuildConfig.APPLICATION_ID}_preferences", 0) + +/** Resolve [attr] from the [Context]'s theme */ +fun Context.resolveAttribute(attr: Int): Int { + val typedValue = TypedValue() + this.theme.resolveAttribute(attr, typedValue, true) + return typedValue.data +} + +/** + * Commit changes to the store from a [FragmentActivity] using a custom implementation of + * [GitOperation] + */ +suspend fun FragmentActivity.commitChange( + message: String, +): Result { + if (!PasswordRepository.isGitRepo()) { + return Ok(Unit) + } + return object : GitOperation(this@commitChange) { + override val commands = + arrayOf( + // Stage all files + git.add().addFilepattern("."), + // Populate the changed files count + git.status(), + // Commit everything! If anything changed, that is. + git.commit().setAll(true).setMessage(message), + ) + + override fun preExecute(): Boolean { + logcat { "Committing with message: '$message'" } + return true + } + } + .execute() +} + +/** Check if [permission] has been granted to the app. */ +fun FragmentActivity.isPermissionGranted(permission: String): Boolean { + return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED +} + +/** + * Show a [Snackbar] in a [FragmentActivity] and correctly anchor it to a + * [com.google.android.material.floatingactionbutton.FloatingActionButton] if one exists in the + * [view] + */ +fun FragmentActivity.snackbar( + view: View = findViewById(android.R.id.content), + message: String, + length: Int = Snackbar.LENGTH_SHORT, +): Snackbar { + val snackbar = Snackbar.make(view, message, length) + snackbar.anchorView = findViewById(R.id.fab) + snackbar.show() + return snackbar +} + +/** Launch an activity denoted by [clazz]. */ +fun FragmentActivity.launchActivity(clazz: Class) { + startActivity(Intent(this, clazz)) +} + +/** Simplifies the common `getString(key, null) ?: defaultValue` case slightly */ +fun SharedPreferences.getString(key: String): String? = getString(key, null) + +/** Convert this [String] to its [Base64] representation */ +fun String.base64(): String { + return Base64.encodeToString(encodeToByteArray(), Base64.NO_WRAP) +} diff --git a/app/src/main/java/app/passwordstore/util/extensions/Extensions.kt b/app/src/main/java/app/passwordstore/util/extensions/Extensions.kt new file mode 100644 index 00000000..9d81587d --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/extensions/Extensions.kt @@ -0,0 +1,98 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.extensions + +import app.passwordstore.data.repo.PasswordRepository +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.runCatching +import java.io.File +import java.util.Date +import logcat.asLog +import org.eclipse.jgit.lib.ObjectId +import org.eclipse.jgit.revwalk.RevCommit + +/** The default OpenPGP provider for the app */ +const val OPENPGP_PROVIDER = "org.sufficientlysecure.keychain" + +/** Clears the given [flag] from the value of this [Int] */ +fun Int.clearFlag(flag: Int): Int { + return this and flag.inv() +} + +/** Checks if this [Int] contains the given [flag] */ +infix fun Int.hasFlag(flag: Int): Boolean { + return this and flag == flag +} + +/** Checks whether this [File] is a directory that contains [other]. */ +fun File.contains(other: File): Boolean { + if (!isDirectory) return false + if (!other.exists()) return false + val relativePath = + runCatching { other.relativeTo(this) } + .getOrElse { + return false + } + // Direct containment is equivalent to the relative path being equal to the filename. + return relativePath.path == other.name +} + +/** + * Checks if this [File] is in the password repository directory as given by + * [PasswordRepository.getRepositoryDirectory] + */ +fun File.isInsideRepository(): Boolean { + return canonicalPath.contains(PasswordRepository.getRepositoryDirectory().canonicalPath) +} + +/** Recursively lists the files in this [File], skipping any directories it encounters. */ +fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toList() + +/** + * Unique SHA-1 hash of this commit as hexadecimal string. + * + * @see RevCommit.getId + */ +val RevCommit.hash: String + get() = ObjectId.toString(id) + +/** + * Time this commit was made with second precision. + * + * @see RevCommit.commitTime + */ +val RevCommit.time: Date + get() { + val epochSeconds = commitTime.toLong() + val epochMilliseconds = epochSeconds * 1000 + return Date(epochMilliseconds) + } + +/** + * Splits this [String] into an [Array] of [String] s, split on the UNIX LF line ending and stripped + * of any empty lines. + */ +fun String.splitLines(): Array { + return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() +} + +/** Alias to [lazy] with thread safety mode always set to [LazyThreadSafetyMode.NONE]. */ +fun unsafeLazy(initializer: () -> T) = lazy(LazyThreadSafetyMode.NONE) { initializer.invoke() } + +/** A convenience extension to turn a [Throwable] with a message into a loggable string. */ +fun Throwable.asLog(message: String): String = "$message\n${asLog()}" + +/** Extension on [Result] that returns if the type is [Ok] */ +fun Result.isOk(): Boolean { + return this is Ok +} + +/** Extension on [Result] that returns if the type is [Err] */ +fun Result.isErr(): Boolean { + return this is Err +} diff --git a/app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt b/app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt new file mode 100644 index 00000000..fca45df6 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt @@ -0,0 +1,41 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.extensions + +import androidx.annotation.IdRes +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.commit +import app.passwordstore.R + +/** Check if [permission] is granted to the app. Aliases to [isPermissionGranted] internally. */ +fun Fragment.isPermissionGranted(permission: String): Boolean { + return requireActivity().isPermissionGranted(permission) +} + +/** Calls `finish()` on the enclosing [androidx.fragment.app.FragmentActivity] */ +fun Fragment.finish() = requireActivity().finish() + +/** + * Perform a [commit] on this [FragmentManager] with custom animations and adding the + * [destinationFragment] to the fragment backstack + */ +fun FragmentManager.performTransactionWithBackStack( + destinationFragment: Fragment, + @IdRes containerViewId: Int = android.R.id.content +) { + commit { + beginTransaction() + addToBackStack(destinationFragment.tag) + setCustomAnimations( + R.animator.slide_in_left, + R.animator.slide_out_left, + R.animator.slide_in_right, + R.animator.slide_out_right + ) + replace(containerViewId, destinationFragment) + } +} diff --git a/app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt b/app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt new file mode 100644 index 00000000..617f3b84 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt @@ -0,0 +1,70 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.extensions + +import android.view.LayoutInflater +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.viewbinding.ViewBinding +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +/** + * Imported from + * https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c + */ +class FragmentViewBindingDelegate( + val fragment: Fragment, + val viewBindingFactory: (View) -> T +) : ReadOnlyProperty { + + private var binding: T? = null + + init { + fragment.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onCreate(owner: LifecycleOwner) { + fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner -> + viewLifecycleOwner.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + binding = null + } + } + ) + } + } + } + ) + } + + override fun getValue(thisRef: Fragment, property: KProperty<*>): T { + val binding = binding + if (binding != null) { + return binding + } + + val lifecycle = fragment.viewLifecycleOwner.lifecycle + if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) { + throw IllegalStateException( + "Should not attempt to get bindings when Fragment views are destroyed." + ) + } + + return viewBindingFactory(thisRef.requireView()).also { this.binding = it } + } +} + +fun Fragment.viewBinding(viewBindingFactory: (View) -> T) = + FragmentViewBindingDelegate(this, viewBindingFactory) + +inline fun AppCompatActivity.viewBinding( + crossinline bindingInflater: (LayoutInflater) -> T +) = unsafeLazy { bindingInflater.invoke(layoutInflater) } diff --git a/app/src/main/java/app/passwordstore/util/features/Feature.kt b/app/src/main/java/app/passwordstore/util/features/Feature.kt new file mode 100644 index 00000000..6e9e77e9 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/features/Feature.kt @@ -0,0 +1,23 @@ +/* + * Copyright © 2014-2022 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.features + +/** List of all feature flags for the app. */ +enum class Feature( + /** Default value for the flag. */ + val defaultValue: Boolean, + /** Key to retrieve the current value for the flag. */ + val configKey: String, +) { + + /** Opt into the new PGP backend powered by the PGPainless library. */ + EnablePGPainlessBackend(false, "enable_pgp_v2_backend"), + ; + + companion object { + @JvmField val VALUES = values() + } +} diff --git a/app/src/main/java/app/passwordstore/util/features/Features.kt b/app/src/main/java/app/passwordstore/util/features/Features.kt new file mode 100644 index 00000000..fbba15f9 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/features/Features.kt @@ -0,0 +1,21 @@ +/* + * Copyright © 2014-2022 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.features + +import android.content.SharedPreferences +import app.passwordstore.injection.prefs.SettingsPreferences +import javax.inject.Inject + +class Features +@Inject +constructor( + @SettingsPreferences private val preferences: SharedPreferences, +) { + + fun isEnabled(feature: Feature): Boolean { + return preferences.getBoolean(feature.configKey, feature.defaultValue) + } +} diff --git a/app/src/main/java/app/passwordstore/util/git/ErrorMessages.kt b/app/src/main/java/app/passwordstore/util/git/ErrorMessages.kt new file mode 100644 index 00000000..3ff932d0 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/ErrorMessages.kt @@ -0,0 +1,66 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.git + +import android.os.RemoteException +import androidx.annotation.StringRes +import app.passwordstore.Application +import app.passwordstore.R +import java.net.UnknownHostException + +/** + * Supertype for all Git-related [Exception] s that can be thrown by [GitCommandExecutor.execute]. + */ +sealed class GitException(@StringRes res: Int, vararg fmt: String) : + Exception(buildMessage(res, *fmt)) { + + override val message = super.message!! + + companion object { + + private fun buildMessage(@StringRes res: Int, vararg fmt: String) = + Application.instance.resources.getString(res, *fmt) + } + + /** Encapsulates possible errors from a [org.eclipse.jgit.api.PullCommand]. */ + sealed class PullException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) { + + object PullRebaseFailed : PullException(R.string.git_pull_rebase_fail_error) + object PullMergeFailed : PullException(R.string.git_pull_merge_fail_error) + } + + /** Encapsulates possible errors from a [org.eclipse.jgit.api.PushCommand]. */ + sealed class PushException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) { + + object NonFastForward : PushException(R.string.git_push_nff_error) + object RemoteRejected : PushException(R.string.git_push_other_error) + class Generic(message: String) : PushException(R.string.git_push_generic_error, message) + } +} + +object ErrorMessages { + + operator fun get(throwable: Throwable?): String { + val resources = Application.instance.resources + if (throwable == null) return resources.getString(R.string.git_unknown_error) + return when (val rootCause = rootCause(throwable)) { + is GitException -> rootCause.message + is UnknownHostException -> resources.getString(R.string.git_unknown_host, throwable.message) + else -> throwable.message ?: resources.getString(R.string.git_unknown_error) + } + } + + private fun rootCause(throwable: Throwable): Throwable { + var cause = throwable + while (cause.cause != null) { + if (cause is GitException) break + val nextCause = cause.cause!! + if (nextCause is RemoteException) break + cause = nextCause + } + return cause + } +} diff --git a/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt b/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt new file mode 100644 index 00000000..40fd0fff --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt @@ -0,0 +1,133 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.git + +import android.widget.Toast +import androidx.fragment.app.FragmentActivity +import app.passwordstore.R +import app.passwordstore.util.extensions.snackbar +import app.passwordstore.util.extensions.unsafeLazy +import app.passwordstore.util.git.GitException.PullException +import app.passwordstore.util.git.GitException.PushException +import app.passwordstore.util.git.operation.GitOperation +import app.passwordstore.util.settings.GitSettings +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.runCatching +import com.google.android.material.snackbar.Snackbar +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.eclipse.jgit.api.CommitCommand +import org.eclipse.jgit.api.PullCommand +import org.eclipse.jgit.api.PushCommand +import org.eclipse.jgit.api.StatusCommand +import org.eclipse.jgit.lib.PersonIdent +import org.eclipse.jgit.transport.RemoteRefUpdate + +class GitCommandExecutor( + private val activity: FragmentActivity, + private val operation: GitOperation, +) { + + private val hiltEntryPoint by unsafeLazy { + EntryPointAccessors.fromApplication( + activity.applicationContext, + GitCommandExecutorEntryPoint::class.java + ) + } + suspend fun execute(): Result { + val gitSettings = hiltEntryPoint.gitSettings() + val snackbar = + activity.snackbar( + message = activity.resources.getString(R.string.git_operation_running), + length = Snackbar.LENGTH_INDEFINITE, + ) + // Count the number of uncommitted files + var nbChanges = 0 + return runCatching { + for (command in operation.commands) { + when (command) { + is StatusCommand -> { + val res = withContext(Dispatchers.IO) { command.call() } + nbChanges = res.uncommittedChanges.size + } + is CommitCommand -> { + // the previous status will eventually be used to avoid a commit + if (nbChanges > 0) { + withContext(Dispatchers.IO) { + val name = gitSettings.authorName.ifEmpty { "root" } + val email = gitSettings.authorEmail.ifEmpty { "localhost" } + val identity = PersonIdent(name, email) + command.setAuthor(identity).setCommitter(identity).call() + } + } + } + is PullCommand -> { + val result = withContext(Dispatchers.IO) { command.call() } + if (result.rebaseResult != null) { + if (!result.rebaseResult.status.isSuccessful) { + throw PullException.PullRebaseFailed + } + } else if (result.mergeResult != null) { + if (!result.mergeResult.mergeStatus.isSuccessful) { + throw PullException.PullMergeFailed + } + } + } + is PushCommand -> { + val results = withContext(Dispatchers.IO) { command.call() } + for (result in results) { + // Code imported (modified) from Gerrit PushOp, license Apache v2 + for (rru in result.remoteUpdates) { + when (rru.status) { + RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD -> + throw PushException.NonFastForward + RemoteRefUpdate.Status.REJECTED_NODELETE, + RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED, + RemoteRefUpdate.Status.NON_EXISTING, + RemoteRefUpdate.Status.NOT_ATTEMPTED, -> + throw PushException.Generic(rru.status.name) + RemoteRefUpdate.Status.REJECTED_OTHER_REASON -> { + throw if ("non-fast-forward" == rru.message) { + PushException.RemoteRejected + } else { + PushException.Generic(rru.message) + } + } + RemoteRefUpdate.Status.UP_TO_DATE -> { + withContext(Dispatchers.Main) { + Toast.makeText( + activity.applicationContext, + activity.applicationContext.getString(R.string.git_push_up_to_date), + Toast.LENGTH_SHORT + ) + .show() + } + } + else -> {} + } + } + } + } + else -> { + withContext(Dispatchers.IO) { command.call() } + } + } + } + } + .also { snackbar.dismiss() } + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface GitCommandExecutorEntryPoint { + + fun gitSettings(): GitSettings + } +} diff --git a/app/src/main/java/app/passwordstore/util/git/GitCommit.kt b/app/src/main/java/app/passwordstore/util/git/GitCommit.kt new file mode 100644 index 00000000..0a5d2020 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/GitCommit.kt @@ -0,0 +1,23 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.git + +import java.util.Date + +/** + * Basic information about a git commit. + * + * @property hash full-length hash of the commit object. + * @property shortMessage the commit's short message (i.e. title line). + * @property authorName name of the commit's author without email address. + * @property time time when the commit was created. + */ +data class GitCommit( + val hash: String, + val shortMessage: String, + val authorName: String, + val time: Date +) diff --git a/app/src/main/java/app/passwordstore/util/git/GitLogModel.kt b/app/src/main/java/app/passwordstore/util/git/GitLogModel.kt new file mode 100644 index 00000000..387c16d9 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/GitLogModel.kt @@ -0,0 +1,60 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.git + +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.util.extensions.asLog +import app.passwordstore.util.extensions.hash +import app.passwordstore.util.extensions.time +import app.passwordstore.util.extensions.unsafeLazy +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.runCatching +import logcat.LogPriority.ERROR +import logcat.logcat +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.revwalk.RevCommit + +private val TAG = GitLogModel::class.java.simpleName + +private fun commits(): Iterable { + val repo = PasswordRepository.repository + if (repo == null) { + logcat(TAG, ERROR) { "Could not access git repository" } + return listOf() + } + return runCatching { Git(repo).log().call() } + .getOrElse { e -> + logcat(TAG, ERROR) { e.asLog("Failed to obtain git commits") } + listOf() + } +} + +/** + * Provides [GitCommit] s from a git-log of the password git repository. + * + * All commits are acquired on the first request to this object. + */ +class GitLogModel { + + // All commits are acquired here at once. Acquiring the commits in batches would not have been + // entirely sensible because the amount of computation required to obtain commit number n from + // the log includes the amount of computation required to obtain commit number n-1 from the log. + // This is because the commit graph is walked from HEAD to the last commit to obtain. + // Additionally, tests with 1000 commits in the log have not produced a significant delay in the + // user experience. + private val cache: MutableList by unsafeLazy { + commits() + .map { GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) } + .toMutableList() + } + val size = cache.size + + fun get(index: Int): GitCommit? { + if (index >= size) + logcat(ERROR) { "Cannot get git commit with index $index. There are only $size." } + return cache.getOrNull(index) + } +} diff --git a/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt b/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt new file mode 100644 index 00000000..65629daa --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt @@ -0,0 +1,66 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.git.operation + +import app.passwordstore.R +import app.passwordstore.util.extensions.unsafeLazy +import app.passwordstore.util.git.sshj.ContinuationContainerActivity +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.eclipse.jgit.api.RebaseCommand +import org.eclipse.jgit.api.ResetCommand +import org.eclipse.jgit.lib.RepositoryState + +class BreakOutOfDetached(callingActivity: ContinuationContainerActivity) : + GitOperation(callingActivity) { + + private val merging = repository.repositoryState == RepositoryState.MERGING + private val resetCommands = + arrayOf( + // git checkout -b conflict-branch + git + .checkout() + .setCreateBranch(true) + .setName("conflicting-$remoteBranch-${System.currentTimeMillis()}"), + // push the changes + git.push().setRemote("origin"), + // switch back to ${gitBranch} + git.checkout().setName(remoteBranch), + ) + + override val commands by unsafeLazy { + if (merging) { + // We need to run some non-command operations first + repository.writeMergeCommitMsg(null) + repository.writeMergeHeads(null) + arrayOf( + // reset hard back to our local HEAD + git.reset().setMode(ResetCommand.ResetType.HARD), + *resetCommands, + ) + } else { + arrayOf( + // abort the rebase + git.rebase().setOperation(RebaseCommand.Operation.ABORT), + *resetCommands, + ) + } + } + + override fun preExecute() = + if (!git.repository.repositoryState.isRebasing && !merging) { + MaterialAlertDialogBuilder(callingActivity) + .setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title)) + .setMessage( + callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded) + ) + .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> + callingActivity.finish() + } + .show() + false + } else { + true + } +} diff --git a/app/src/main/java/app/passwordstore/util/git/operation/CloneOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/CloneOperation.kt new file mode 100644 index 00000000..88178125 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/operation/CloneOperation.kt @@ -0,0 +1,24 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.git.operation + +import app.passwordstore.util.git.sshj.ContinuationContainerActivity +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.GitCommand + +/** + * Creates a new clone operation + * + * @param uri URL to clone the repository from + * @param callingActivity the calling activity + */ +class CloneOperation(callingActivity: ContinuationContainerActivity, uri: String) : + GitOperation(callingActivity) { + + override val commands: Array> = + arrayOf( + Git.cloneRepository().setBranch(remoteBranch).setDirectory(repository.workTree).setURI(uri), + ) +} diff --git a/app/src/main/java/app/passwordstore/util/git/operation/CredentialFinder.kt b/app/src/main/java/app/passwordstore/util/git/operation/CredentialFinder.kt new file mode 100644 index 00000000..16364f0c --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/operation/CredentialFinder.kt @@ -0,0 +1,116 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.git.operation + +import android.annotation.SuppressLint +import android.content.SharedPreferences +import android.view.LayoutInflater +import android.view.WindowManager +import androidx.annotation.StringRes +import androidx.core.content.edit +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.FragmentActivity +import app.passwordstore.R +import app.passwordstore.injection.prefs.GitPreferences +import app.passwordstore.util.git.sshj.InteractivePasswordFinder +import app.passwordstore.util.settings.AuthMode +import app.passwordstore.util.settings.PreferenceKeys +import com.google.android.material.checkbox.MaterialCheckBox +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume + +class CredentialFinder(val callingActivity: FragmentActivity, val authMode: AuthMode) : + InteractivePasswordFinder() { + + private val hiltEntryPoint = + EntryPointAccessors.fromApplication( + callingActivity.applicationContext, + CredentialFinderEntryPoint::class.java, + ) + + override fun askForPassword(cont: Continuation, isRetry: Boolean) { + val gitOperationPrefs = hiltEntryPoint.gitPrefs() + val credentialPref: String + @StringRes val messageRes: Int + @StringRes val hintRes: Int + @StringRes val rememberRes: Int + @StringRes val errorRes: Int + when (authMode) { + AuthMode.SshKey -> { + credentialPref = PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE + messageRes = R.string.passphrase_dialog_text + hintRes = R.string.ssh_keygen_passphrase + rememberRes = R.string.git_operation_remember_passphrase + errorRes = R.string.git_operation_wrong_passphrase + } + AuthMode.Password -> { + // Could be either an SSH or an HTTPS password + credentialPref = PreferenceKeys.HTTPS_PASSWORD + messageRes = R.string.password_dialog_text + hintRes = R.string.git_operation_hint_password + rememberRes = R.string.git_operation_remember_password + errorRes = R.string.git_operation_wrong_password + } + else -> + throw IllegalStateException("Only SshKey and Password connection mode ask for passwords") + } + if (isRetry) gitOperationPrefs.edit { remove(credentialPref) } + val storedCredential = gitOperationPrefs.getString(credentialPref, null) + if (storedCredential == null) { + val layoutInflater = LayoutInflater.from(callingActivity) + + @SuppressLint("InflateParams") + val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null) + val credentialLayout = + dialogView.findViewById(R.id.git_auth_passphrase_layout) + val editCredential = dialogView.findViewById(R.id.git_auth_credential) + editCredential.setHint(hintRes) + val rememberCredential = + dialogView.findViewById(R.id.git_auth_remember_credential) + rememberCredential.setText(rememberRes) + if (isRetry) { + credentialLayout.error = callingActivity.resources.getString(errorRes) + // Reset error when user starts entering a password + editCredential.doOnTextChanged { _, _, _, _ -> credentialLayout.error = null } + } + MaterialAlertDialogBuilder(callingActivity) + .run { + setTitle(R.string.passphrase_dialog_title) + setMessage(messageRes) + setView(dialogView) + setPositiveButton(R.string.dialog_ok) { _, _ -> + val credential = editCredential.text.toString() + if (rememberCredential.isChecked) { + gitOperationPrefs.edit { putString(credentialPref, credential) } + } + cont.resume(credential) + } + setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(null) } + setOnCancelListener { cont.resume(null) } + create() + } + .run { + window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) + show() + } + } else { + cont.resume(storedCredential) + } + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface CredentialFinderEntryPoint { + @GitPreferences fun gitPrefs(): SharedPreferences + } +} diff --git a/app/src/main/java/app/passwordstore/util/git/operation/GcOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/GcOperation.kt new file mode 100644 index 00000000..556c899e --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/operation/GcOperation.kt @@ -0,0 +1,22 @@ +/* + * Copyright © 2014-2022 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.git.operation + +import app.passwordstore.util.git.sshj.ContinuationContainerActivity +import org.eclipse.jgit.api.GitCommand + +/** + * Run an aggressive garbage collection job on the repository, expiring every loose object to + * achieve the best compression. + */ +class GcOperation( + callingActivity: ContinuationContainerActivity, +) : GitOperation(callingActivity) { + + override val requiresAuth: Boolean = false + override val commands: Array> = + arrayOf(git.gc().setAggressive(true).setExpire(null)) +} diff --git a/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt new file mode 100644 index 00000000..2f13b5ad --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt @@ -0,0 +1,245 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.git.operation + +import android.content.Intent +import android.widget.Toast +import androidx.fragment.app.FragmentActivity +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.sshkeygen.SshKeyGenActivity +import app.passwordstore.ui.sshkeygen.SshKeyImportActivity +import app.passwordstore.util.auth.BiometricAuthenticator +import app.passwordstore.util.auth.BiometricAuthenticator.Result.* +import app.passwordstore.util.git.GitCommandExecutor +import app.passwordstore.util.git.sshj.ContinuationContainerActivity +import app.passwordstore.util.git.sshj.SshAuthMethod +import app.passwordstore.util.git.sshj.SshKey +import app.passwordstore.util.git.sshj.SshjSessionFactory +import app.passwordstore.util.settings.AuthMode +import app.passwordstore.util.settings.GitSettings +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import logcat.LogPriority.ERROR +import logcat.asLog +import logcat.logcat +import net.schmizz.sshj.common.DisconnectReason +import net.schmizz.sshj.common.SSHException +import net.schmizz.sshj.userauth.password.PasswordFinder +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.GitCommand +import org.eclipse.jgit.api.TransportCommand +import org.eclipse.jgit.errors.UnsupportedCredentialItem +import org.eclipse.jgit.transport.CredentialItem +import org.eclipse.jgit.transport.CredentialsProvider +import org.eclipse.jgit.transport.SshTransport +import org.eclipse.jgit.transport.Transport +import org.eclipse.jgit.transport.URIish + +/** + * Creates a new git operation + * + * @param callingActivity the calling activity + */ +abstract class GitOperation(protected val callingActivity: FragmentActivity) { + + /** List of [GitCommand]s that are executed by an operation. */ + abstract val commands: Array> + + /** Whether the operation requires authentication or not. */ + open val requiresAuth: Boolean = true + private val hostKeyFile = callingActivity.filesDir.resolve(".host_key") + private var sshSessionFactory: SshjSessionFactory? = null + private val hiltEntryPoint = + EntryPointAccessors.fromApplication( + callingActivity.applicationContext, + GitOperationEntryPoint::class.java + ) + + protected val repository = PasswordRepository.repository!! + protected val git = Git(repository) + protected val remoteBranch = hiltEntryPoint.gitSettings().branch + private val authActivity + get() = callingActivity as ContinuationContainerActivity + + private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) : + CredentialsProvider() { + + private var cachedPassword: CharArray? = null + + override fun isInteractive() = true + + override fun get(uri: URIish?, vararg items: CredentialItem): Boolean { + for (item in items) { + when (item) { + is CredentialItem.Username -> item.value = uri?.user + is CredentialItem.Password -> { + item.value = + cachedPassword?.clone() + ?: passwordFinder.reqPassword(null).also { cachedPassword = it.clone() } + } + else -> UnsupportedCredentialItem(uri, item.javaClass.name) + } + } + return true + } + + override fun supports(vararg items: CredentialItem) = + items.all { it is CredentialItem.Username || it is CredentialItem.Password } + + override fun reset(uri: URIish?) { + cachedPassword?.fill(0.toChar()) + cachedPassword = null + } + } + + private fun getSshKey(make: Boolean) { + runCatching { + val intent = + if (make) { + Intent(callingActivity.applicationContext, SshKeyGenActivity::class.java) + } else { + Intent(callingActivity.applicationContext, SshKeyImportActivity::class.java) + } + callingActivity.startActivity(intent) + } + .onFailure { e -> logcat(ERROR) { e.asLog() } } + } + + private fun registerAuthProviders( + authMethod: SshAuthMethod, + credentialsProvider: CredentialsProvider? = null + ) { + sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile) + commands.filterIsInstance>().forEach { command -> + command.setTransportConfigCallback { transport: Transport -> + (transport as? SshTransport)?.sshSessionFactory = sshSessionFactory + credentialsProvider?.let { transport.credentialsProvider = it } + } + command.setTimeout(CONNECT_TIMEOUT) + } + } + + /** Executes the GitCommand in an async task. */ + suspend fun execute(): Result { + if (!preExecute()) { + return Ok(Unit) + } + val operationResult = + GitCommandExecutor( + callingActivity, + this, + ) + .execute() + postExecute() + return operationResult + } + + private fun onMissingSshKeyFile() { + MaterialAlertDialogBuilder(callingActivity) + .setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text)) + .setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title)) + .setPositiveButton( + callingActivity.resources.getString(R.string.ssh_preferences_dialog_import) + ) { _, _ -> getSshKey(false) } + .setNegativeButton( + callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate) + ) { _, _ -> getSshKey(true) } + .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ -> + // Finish the blank GitActivity so user doesn't have to press back + callingActivity.finish() + } + .show() + } + + suspend fun executeAfterAuthentication(authMode: AuthMode): Result { + when (authMode) { + AuthMode.SshKey -> + if (SshKey.exists) { + if (SshKey.mustAuthenticate) { + val result = + withContext(Dispatchers.Main) { + suspendCoroutine { cont -> + BiometricAuthenticator.authenticate( + callingActivity, + R.string.biometric_prompt_title_ssh_auth + ) { result -> if (result !is Failure) cont.resume(result) } + } + } + when (result) { + is Success -> { + registerAuthProviders(SshAuthMethod.SshKey(authActivity)) + } + is Cancelled -> { + return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)) + } + is Failure -> { + throw IllegalStateException("Biometric authentication failures should be ignored") + } + else -> { + // There is a chance we succeed if the user recently confirmed + // their screen lock. Doing so would have a potential to confuse + // users though, who might deduce that the screen lock + // protection is not effective. Hence, we fail with an error. + Toast.makeText( + callingActivity.applicationContext, + R.string.biometric_auth_generic_failure, + Toast.LENGTH_LONG + ) + .show() + callingActivity.finish() + } + } + } else { + registerAuthProviders(SshAuthMethod.SshKey(authActivity)) + } + } else { + onMissingSshKeyFile() + // This would correctly cancel the operation but won't surface a user-visible + // error, allowing users to make the SSH key selection. + return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)) + } + AuthMode.OpenKeychain -> registerAuthProviders(SshAuthMethod.OpenKeychain(authActivity)) + AuthMode.Password -> { + val httpsCredentialProvider = + HttpsCredentialsProvider(CredentialFinder(callingActivity, AuthMode.Password)) + registerAuthProviders(SshAuthMethod.Password(authActivity), httpsCredentialProvider) + } + AuthMode.None -> {} + } + return execute() + } + + /** Called before execution of the Git operation. Return false to cancel. */ + open fun preExecute() = true + + private suspend fun postExecute() { + withContext(Dispatchers.IO) { sshSessionFactory?.close() } + } + + companion object { + + /** Timeout in seconds before [TransportCommand] will abort a stalled IO operation. */ + private const val CONNECT_TIMEOUT = 10 + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface GitOperationEntryPoint { + fun gitSettings(): GitSettings + } +} diff --git a/app/src/main/java/app/passwordstore/util/git/operation/PullOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/PullOperation.kt new file mode 100644 index 00000000..75b6fc1a --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/operation/PullOperation.kt @@ -0,0 +1,35 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.git.operation + +import app.passwordstore.util.git.sshj.ContinuationContainerActivity +import org.eclipse.jgit.api.GitCommand + +class PullOperation( + callingActivity: ContinuationContainerActivity, + rebase: Boolean, +) : GitOperation(callingActivity) { + + /** + * The story of why the pull operation is committing files goes like this: Once upon a time when + * the world was burning and Blade Runner 2049 was real life (in the worst way), we were made + * aware that Bitbucket is actually bad, and disables a neat OpenSSH feature called multiplexing. + * So now, rather than being able to do a [SyncOperation], we'd have to first do a [PullOperation] + * and then a [PushOperation]. To make the behavior identical despite this suboptimal situation, + * we opted to replicate [SyncOperation]'s committing flow within [PullOperation], almost exactly + * replicating [SyncOperation] but leaving the pushing part to [PushOperation]. + */ + override val commands: Array> = + arrayOf( + // Stage all files + git.add().addFilepattern("."), + // Populate the changed files count + git.status(), + // Commit everything! If needed, obviously. + git.commit().setAll(true).setMessage("[Android Password Store] Sync"), + // Pull and rebase on top of the remote branch + git.pull().setRebase(rebase).setRemote("origin"), + ) +} diff --git a/app/src/main/java/app/passwordstore/util/git/operation/PushOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/PushOperation.kt new file mode 100644 index 00000000..386d79e6 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/operation/PushOperation.kt @@ -0,0 +1,17 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.git.operation + +import app.passwordstore.util.git.sshj.ContinuationContainerActivity +import org.eclipse.jgit.api.GitCommand + +class PushOperation(callingActivity: ContinuationContainerActivity) : + GitOperation(callingActivity) { + + override val commands: Array> = + arrayOf( + git.push().setPushAll().setRemote("origin"), + ) +} diff --git a/app/src/main/java/app/passwordstore/util/git/operation/ResetToRemoteOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/ResetToRemoteOperation.kt new file mode 100644 index 00000000..7c8cee93 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/operation/ResetToRemoteOperation.kt @@ -0,0 +1,27 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.git.operation + +import app.passwordstore.util.git.sshj.ContinuationContainerActivity +import org.eclipse.jgit.api.ResetCommand + +class ResetToRemoteOperation(callingActivity: ContinuationContainerActivity) : + GitOperation(callingActivity) { + + override val commands = + arrayOf( + // Stage all files + git.add().addFilepattern("."), + // Fetch everything from the origin remote + git.fetch().setRemote("origin"), + // Do a hard reset to the remote branch. Equivalent to git reset --hard + // origin/$remoteBranch + git.reset().setRef("origin/$remoteBranch").setMode(ResetCommand.ResetType.HARD), + // Force-create $remoteBranch if it doesn't exist. This covers the case where you + // switched + // branches from 'master' to anything else. + git.branchCreate().setName(remoteBranch).setForce(true), + ) +} diff --git a/app/src/main/java/app/passwordstore/util/git/operation/SyncOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/SyncOperation.kt new file mode 100644 index 00000000..226fd753 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/operation/SyncOperation.kt @@ -0,0 +1,27 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.git.operation + +import app.passwordstore.util.git.sshj.ContinuationContainerActivity + +class SyncOperation( + callingActivity: ContinuationContainerActivity, + rebase: Boolean, +) : GitOperation(callingActivity) { + + override val commands = + arrayOf( + // Stage all files + git.add().addFilepattern("."), + // Populate the changed files count + git.status(), + // Commit everything! If needed, obviously. + git.commit().setAll(true).setMessage("[Android Password Store] Sync"), + // Pull and rebase on top of the remote branch + git.pull().setRebase(rebase).setRemote("origin"), + // Push it all back + git.push().setPushAll().setRemote("origin"), + ) +} diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/ContinuationContainerActivity.kt b/app/src/main/java/app/passwordstore/util/git/sshj/ContinuationContainerActivity.kt new file mode 100644 index 00000000..10872a24 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/sshj/ContinuationContainerActivity.kt @@ -0,0 +1,34 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.git.sshj + +import android.content.Intent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.LayoutRes +import androidx.appcompat.app.AppCompatActivity +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import net.schmizz.sshj.common.DisconnectReason +import net.schmizz.sshj.userauth.UserAuthException + +/** Workaround for https://msfjarvis.dev/aps/issue/1164 */ +open class ContinuationContainerActivity : AppCompatActivity { + + constructor() : super() + constructor(@LayoutRes layoutRes: Int) : super(layoutRes) + + var stashedCont: Continuation? = null + + val continueAfterUserInteraction = + registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> + stashedCont?.let { cont -> + stashedCont = null + val data = result.data + if (data != null) cont.resume(data) + else cont.resumeWithException(UserAuthException(DisconnectReason.AUTH_CANCELLED_BY_USER)) + } + } +} diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/OpenKeychainKeyProvider.kt b/app/src/main/java/app/passwordstore/util/git/sshj/OpenKeychainKeyProvider.kt new file mode 100644 index 00000000..7603059f --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/sshj/OpenKeychainKeyProvider.kt @@ -0,0 +1,225 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.git.sshj + +import android.app.PendingIntent +import android.content.Intent +import androidx.activity.result.IntentSenderRequest +import androidx.core.content.edit +import androidx.lifecycle.lifecycleScope +import app.passwordstore.util.extensions.OPENPGP_PROVIDER +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.settings.PreferenceKeys +import java.io.Closeable +import java.security.PublicKey +import java.security.interfaces.ECKey +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import logcat.logcat +import net.schmizz.sshj.common.DisconnectReason +import net.schmizz.sshj.common.KeyType +import net.schmizz.sshj.userauth.UserAuthException +import net.schmizz.sshj.userauth.keyprovider.KeyProvider +import org.openintents.ssh.authentication.ISshAuthenticationService +import org.openintents.ssh.authentication.SshAuthenticationApi +import org.openintents.ssh.authentication.SshAuthenticationApiError +import org.openintents.ssh.authentication.SshAuthenticationConnection +import org.openintents.ssh.authentication.request.KeySelectionRequest +import org.openintents.ssh.authentication.request.Request +import org.openintents.ssh.authentication.request.SigningRequest +import org.openintents.ssh.authentication.request.SshPublicKeyRequest +import org.openintents.ssh.authentication.response.KeySelectionResponse +import org.openintents.ssh.authentication.response.Response +import org.openintents.ssh.authentication.response.SigningResponse +import org.openintents.ssh.authentication.response.SshPublicKeyResponse + +class OpenKeychainKeyProvider private constructor(val activity: ContinuationContainerActivity) : + KeyProvider, Closeable { + + companion object { + + suspend fun prepareAndUse( + activity: ContinuationContainerActivity, + block: (provider: OpenKeychainKeyProvider) -> Unit + ) { + withContext(Dispatchers.Main) { OpenKeychainKeyProvider(activity) }.prepareAndUse(block) + } + } + + private sealed class ApiResponse { + data class Success(val response: Response) : ApiResponse() + data class GeneralError(val exception: Exception) : ApiResponse() + data class NoSuchKey(val exception: Exception) : ApiResponse() + } + + private val context = activity.applicationContext + private val sshServiceConnection = SshAuthenticationConnection(context, OPENPGP_PROVIDER) + private val preferences = context.sharedPrefs + private lateinit var sshServiceApi: SshAuthenticationApi + + private var keyId + get() = preferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) + set(value) { + preferences.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, value) } + } + private var publicKey: PublicKey? = null + private var privateKey: OpenKeychainPrivateKey? = null + + private suspend fun prepareAndUse(block: (provider: OpenKeychainKeyProvider) -> Unit) { + prepare() + use(block) + } + + private suspend fun prepare() { + sshServiceApi = suspendCoroutine { cont -> + sshServiceConnection.connect( + object : SshAuthenticationConnection.OnBound { + override fun onBound(sshAgent: ISshAuthenticationService) { + logcat { "Bound to SshAuthenticationApi: $OPENPGP_PROVIDER" } + cont.resume(SshAuthenticationApi(context, sshAgent)) + } + + override fun onError() { + throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable") + } + } + ) + } + + if (keyId == null) { + selectKey() + } + check(keyId != null) + fetchPublicKey() + makePrivateKey() + } + + private suspend fun fetchPublicKey(isRetry: Boolean = false) { + when (val sshPublicKeyResponse = executeApiRequest(SshPublicKeyRequest(keyId))) { + is ApiResponse.Success -> { + val response = sshPublicKeyResponse.response as SshPublicKeyResponse + val sshPublicKey = response.sshPublicKey!! + publicKey = + parseSshPublicKey(sshPublicKey) + ?: throw IllegalStateException("OpenKeychain API returned invalid SSH key") + } + is ApiResponse.NoSuchKey -> + if (isRetry) { + throw sshPublicKeyResponse.exception + } else { + // Allow the user to reselect an authentication key and retry + selectKey() + fetchPublicKey(true) + } + is ApiResponse.GeneralError -> throw sshPublicKeyResponse.exception + } + } + + private suspend fun selectKey() { + when (val keySelectionResponse = executeApiRequest(KeySelectionRequest())) { + is ApiResponse.Success -> + keyId = (keySelectionResponse.response as KeySelectionResponse).keyId + is ApiResponse.GeneralError -> throw keySelectionResponse.exception + is ApiResponse.NoSuchKey -> throw keySelectionResponse.exception + } + } + + private suspend fun executeApiRequest( + request: Request, + resultOfUserInteraction: Intent? = null + ): ApiResponse { + logcat { "executeRequest($request) called" } + val result = + withContext(Dispatchers.Main) { + // If the request required user interaction, the data returned from the + // PendingIntent + // is used as the real request. + sshServiceApi.executeApi(resultOfUserInteraction ?: request.toIntent())!! + } + return parseResult(request, result).also { logcat { "executeRequest($request): $it" } } + } + + private suspend fun parseResult(request: Request, result: Intent): ApiResponse { + return when ( + result.getIntExtra( + SshAuthenticationApi.EXTRA_RESULT_CODE, + SshAuthenticationApi.RESULT_CODE_ERROR + ) + ) { + SshAuthenticationApi.RESULT_CODE_SUCCESS -> { + ApiResponse.Success( + when (request) { + is KeySelectionRequest -> KeySelectionResponse(result) + is SshPublicKeyRequest -> SshPublicKeyResponse(result) + is SigningRequest -> SigningResponse(result) + else -> throw IllegalArgumentException("Unsupported OpenKeychain request type") + } + ) + } + SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val pendingIntent: PendingIntent = + result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!! + val resultOfUserInteraction: Intent = + withContext(Dispatchers.Main) { + suspendCoroutine { cont -> + activity.stashedCont = cont + activity.continueAfterUserInteraction.launch( + IntentSenderRequest.Builder(pendingIntent).build() + ) + } + } + executeApiRequest(request, resultOfUserInteraction) + } + else -> { + val error = + result.getParcelableExtra(SshAuthenticationApi.EXTRA_ERROR) + val exception = + UserAuthException( + DisconnectReason.UNKNOWN, + "Request ${request::class.simpleName} failed: ${error?.message}" + ) + when (error?.error) { + SshAuthenticationApiError.NO_AUTH_KEY, + SshAuthenticationApiError.NO_SUCH_KEY -> ApiResponse.NoSuchKey(exception) + else -> ApiResponse.GeneralError(exception) + } + } + } + } + + private fun makePrivateKey() { + check(keyId != null && publicKey != null) + privateKey = + object : OpenKeychainPrivateKey { + override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) = + when ( + val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm)) + ) { + is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature + is ApiResponse.GeneralError -> throw signingResponse.exception + is ApiResponse.NoSuchKey -> throw signingResponse.exception + } + + override fun getAlgorithm() = publicKey!!.algorithm + override fun getParams() = (publicKey as? ECKey)?.params + } + } + + override fun close() { + activity.lifecycleScope.launch { + withContext(Dispatchers.Main) { activity.continueAfterUserInteraction.unregister() } + } + sshServiceConnection.disconnect() + } + + override fun getPrivate() = privateKey + + override fun getPublic() = publicKey + + override fun getType(): KeyType = KeyType.fromKey(publicKey) +} diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt b/app/src/main/java/app/passwordstore/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt new file mode 100644 index 00000000..611ba9e0 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt @@ -0,0 +1,103 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.git.sshj + +import com.hierynomus.sshj.key.KeyAlgorithm +import java.io.ByteArrayOutputStream +import java.security.PrivateKey +import java.security.interfaces.ECKey +import kotlinx.coroutines.runBlocking +import net.schmizz.sshj.common.Buffer +import net.schmizz.sshj.common.Factory +import net.schmizz.sshj.signature.Signature +import org.openintents.ssh.authentication.SshAuthenticationApi + +interface OpenKeychainPrivateKey : PrivateKey, ECKey { + + suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray + + override fun getFormat() = null + override fun getEncoded() = null +} + +class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named) : + Factory.Named 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/app/passwordstore/util/git/sshj/SshKey.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt new file mode 100644 index 00000000..15753da9 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt @@ -0,0 +1,372 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.git.sshj + +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.OpenableColumns +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyInfo +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.core.content.edit +import androidx.security.crypto.EncryptedFile +import androidx.security.crypto.MasterKey +import app.passwordstore.Application +import app.passwordstore.R +import app.passwordstore.util.extensions.getEncryptedGitPrefs +import app.passwordstore.util.extensions.getString +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.extensions.unsafeLazy +import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.runCatching +import java.io.File +import java.io.IOException +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.PublicKey +import javax.crypto.SecretKey +import javax.crypto.SecretKeyFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import logcat.asLog +import logcat.logcat +import net.i2p.crypto.eddsa.EdDSAPrivateKey +import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable +import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec +import net.schmizz.sshj.SSHClient +import net.schmizz.sshj.common.Buffer +import net.schmizz.sshj.common.KeyType +import net.schmizz.sshj.userauth.keyprovider.KeyProvider + +private const val PROVIDER_ANDROID_KEY_STORE = "AndroidKeyStore" +private const val KEYSTORE_ALIAS = "sshkey" +private const val ANDROIDX_SECURITY_KEYSET_PREF_NAME = "androidx_sshkey_keyset_prefs" + +private val androidKeystore: KeyStore by unsafeLazy { + KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) } +} + +private val KeyStore.sshPrivateKey + get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey + +private val KeyStore.sshPublicKey + get() = getCertificate(KEYSTORE_ALIAS)?.publicKey + +fun parseSshPublicKey(sshPublicKey: String): PublicKey? { + val sshKeyParts = sshPublicKey.split("""\s+""".toRegex()) + if (sshKeyParts.size < 2) return null + return Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey() +} + +fun toSshPublicKey(publicKey: PublicKey): String { + val rawPublicKey = Buffer.PlainBuffer().putPublicKey(publicKey).compactData + val keyType = KeyType.fromKey(publicKey) + return "$keyType ${Base64.encodeToString(rawPublicKey, Base64.NO_WRAP)}" +} + +object SshKey { + + val sshPublicKey + get() = if (publicKeyFile.exists()) publicKeyFile.readText() else null + val canShowSshPublicKey + get() = type in listOf(Type.LegacyGenerated, Type.KeystoreNative, Type.KeystoreWrappedEd25519) + val exists + get() = type != null + val mustAuthenticate: Boolean + get() { + return runCatching { + if (type !in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519)) return false + when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) { + is PrivateKey -> { + val factory = KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE) + return factory.getKeySpec(key, KeyInfo::class.java).isUserAuthenticationRequired + } + is SecretKey -> { + val factory = SecretKeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE) + (factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo).isUserAuthenticationRequired + } + else -> throw IllegalStateException("SSH key does not exist in Keystore") + } + } + .getOrElse { error -> + // It is fine to swallow the exception here since it will reappear when the key + // is + // used for SSH authentication and can then be shown in the UI. + logcat { error.asLog() } + false + } + } + + private val context: Context + get() = Application.instance.applicationContext + + private val privateKeyFile + get() = File(context.filesDir, ".ssh_key") + private val publicKeyFile + get() = File(context.filesDir, ".ssh_key.pub") + + private var type: Type? + get() = Type.fromValue(context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_KEY_TYPE)) + set(value) = + context.sharedPrefs.edit { putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value) } + + private val isStrongBoxSupported by unsafeLazy { + if (Build.VERSION.SDK_INT >= 28) + context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) + else false + } + + private enum class Type(val value: String) { + Imported("imported"), + KeystoreNative("keystore_native"), + KeystoreWrappedEd25519("keystore_wrapped_ed25519"), + + // Behaves like `Imported`, but allows to view the public key. + LegacyGenerated("legacy_generated"), + ; + + companion object { + + fun fromValue(value: String?): Type? = values().associateBy { it.value }[value] + } + } + + enum class Algorithm( + val algorithm: String, + val applyToSpec: KeyGenParameterSpec.Builder.() -> Unit + ) { + Rsa( + KeyProperties.KEY_ALGORITHM_RSA, + { + setKeySize(3072) + setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1) + setDigests( + KeyProperties.DIGEST_SHA1, + KeyProperties.DIGEST_SHA256, + KeyProperties.DIGEST_SHA512 + ) + } + ), + Ecdsa( + KeyProperties.KEY_ALGORITHM_EC, + { + setKeySize(256) + setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1")) + setDigests(KeyProperties.DIGEST_SHA256) + if (Build.VERSION.SDK_INT >= 28) { + setIsStrongBoxBacked(isStrongBoxSupported) + } + } + ), + } + + private fun delete() { + androidKeystore.deleteEntry(KEYSTORE_ALIAS) + // Remove Tink key set used by AndroidX's EncryptedFile. + context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE).edit { + clear() + } + if (privateKeyFile.isFile) { + privateKeyFile.delete() + } + if (publicKeyFile.isFile) { + publicKeyFile.delete() + } + context.getEncryptedGitPrefs().edit { remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) } + type = null + } + + fun import(uri: Uri) { + // First check whether the content at uri is likely an SSH private key. + val fileSize = + context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { + cursor -> + // Cursor returns only a single row. + cursor.moveToFirst() + cursor.getInt(0) + } + ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) + + // We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes. + if (fileSize > 100_000 || fileSize == 0) + throw IllegalArgumentException( + context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message) + ) + + val sshKeyInputStream = + context.contentResolver.openInputStream(uri) + ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) + val lines = sshKeyInputStream.bufferedReader().readLines() + + // The file must have more than 2 lines, and the first and last line must have private key + // markers. + if ( + lines.size < 2 || + !Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) || + !Regex("END .* PRIVATE KEY").containsMatchIn(lines.last()) + ) + throw IllegalArgumentException( + context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message) + ) + + // At this point, we are reasonably confident that we have actually been provided a private + // key and delete the old key. + delete() + // Canonicalize line endings to '\n'. + privateKeyFile.writeText(lines.joinToString("\n")) + + type = Type.Imported + } + + @Deprecated("To be used only in Migrations.kt") + fun useLegacyKey(isGenerated: Boolean) { + type = if (isGenerated) Type.LegacyGenerated else Type.Imported + } + + @Suppress("BlockingMethodInNonBlockingContext") + private suspend fun getOrCreateWrappingMasterKey(requireAuthentication: Boolean) = + withContext(Dispatchers.IO) { + MasterKey.Builder(context, KEYSTORE_ALIAS).run { + setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + setRequestStrongBoxBacked(true) + setUserAuthenticationRequired(requireAuthentication, 15) + build() + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + private suspend fun getOrCreateWrappedPrivateKeyFile(requireAuthentication: Boolean) = + withContext(Dispatchers.IO) { + EncryptedFile.Builder( + context, + privateKeyFile, + getOrCreateWrappingMasterKey(requireAuthentication), + EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB + ) + .run { + setKeysetPrefName(ANDROIDX_SECURITY_KEYSET_PREF_NAME) + build() + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) = + withContext(Dispatchers.IO) { + delete() + + val encryptedPrivateKeyFile = getOrCreateWrappedPrivateKeyFile(requireAuthentication) + // Generate the ed25519 key pair and encrypt the private key. + val keyPair = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair() + encryptedPrivateKeyFile.openFileOutput().use { os -> + os.write((keyPair.private as EdDSAPrivateKey).seed) + } + + // Write public key in SSH format to .ssh_key.pub. + publicKeyFile.writeText(toSshPublicKey(keyPair.public)) + + type = Type.KeystoreWrappedEd25519 + } + + fun generateKeystoreNativeKey(algorithm: Algorithm, requireAuthentication: Boolean) { + delete() + + // Generate Keystore-backed private key. + val parameterSpec = + KeyGenParameterSpec.Builder(KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN).run { + apply(algorithm.applyToSpec) + if (requireAuthentication) { + setUserAuthenticationRequired(true) + if (Build.VERSION.SDK_INT >= 30) { + setUserAuthenticationParameters(30, KeyProperties.AUTH_DEVICE_CREDENTIAL) + } else { + @Suppress("DEPRECATION") setUserAuthenticationValidityDurationSeconds(30) + } + } + build() + } + val keyPair = + KeyPairGenerator.getInstance(algorithm.algorithm, PROVIDER_ANDROID_KEY_STORE).run { + initialize(parameterSpec) + generateKeyPair() + } + + // Write public key in SSH format to .ssh_key.pub. + publicKeyFile.writeText(toSshPublicKey(keyPair.public)) + + type = Type.KeystoreNative + } + + fun provide(client: SSHClient, passphraseFinder: InteractivePasswordFinder): KeyProvider? = + when (type) { + Type.LegacyGenerated, + Type.Imported -> client.loadKeys(privateKeyFile.absolutePath, passphraseFinder) + Type.KeystoreNative -> KeystoreNativeKeyProvider + Type.KeystoreWrappedEd25519 -> KeystoreWrappedEd25519KeyProvider + null -> null + } + + private object KeystoreNativeKeyProvider : KeyProvider { + + override fun getPublic(): PublicKey = + runCatching { androidKeystore.sshPublicKey!! } + .getOrElse { error -> + logcat { error.asLog() } + throw IOException( + "Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", + error + ) + } + + override fun getPrivate(): PrivateKey = + runCatching { androidKeystore.sshPrivateKey!! } + .getOrElse { error -> + logcat { error.asLog() } + throw IOException( + "Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore", + error + ) + } + + override fun getType(): KeyType = KeyType.fromKey(public) + } + + private object KeystoreWrappedEd25519KeyProvider : KeyProvider { + + override fun getPublic(): PublicKey = + runCatching { parseSshPublicKey(sshPublicKey!!)!! } + .getOrElse { error -> + logcat { error.asLog() } + throw IOException("Failed to get the public key for wrapped ed25519 key", error) + } + + override fun getPrivate(): PrivateKey = + runCatching { + // The current MasterKey API does not allow getting a reference to an existing + // one + // without specifying the KeySpec for a new one. However, the value for passed + // here + // for `requireAuthentication` is not used as the key already exists at this + // point. + val encryptedPrivateKeyFile = runBlocking { getOrCreateWrappedPrivateKeyFile(false) } + val rawPrivateKey = encryptedPrivateKeyFile.openFileInput().use { it.readBytes() } + EdDSAPrivateKey( + EdDSAPrivateKeySpec(rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC) + ) + } + .getOrElse { error -> + logcat { error.asLog() } + throw IOException("Failed to unwrap wrapped ed25519 key", error) + } + + override fun getType(): KeyType = KeyType.fromKey(public) + } +} diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshjConfig.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshjConfig.kt new file mode 100644 index 00000000..7522de50 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshjConfig.kt @@ -0,0 +1,289 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.git.sshj + +import com.github.michaelbull.result.runCatching +import com.hierynomus.sshj.key.KeyAlgorithms +import com.hierynomus.sshj.transport.cipher.BlockCiphers +import com.hierynomus.sshj.transport.cipher.GcmCiphers +import com.hierynomus.sshj.transport.kex.ExtInfoClientFactory +import com.hierynomus.sshj.transport.mac.Macs +import com.hierynomus.sshj.userauth.keyprovider.OpenSSHKeyV1KeyFile +import java.security.Security +import logcat.LogPriority.ERROR +import logcat.LogPriority.INFO +import logcat.LogPriority.VERBOSE +import logcat.LogPriority.WARN +import logcat.asLog +import logcat.logcat +import net.schmizz.keepalive.KeepAliveProvider +import net.schmizz.sshj.ConfigImpl +import net.schmizz.sshj.common.LoggerFactory +import net.schmizz.sshj.common.SecurityUtils +import net.schmizz.sshj.transport.compression.NoneCompression +import net.schmizz.sshj.transport.kex.Curve25519SHA256 +import net.schmizz.sshj.transport.kex.Curve25519SHA256.FactoryLibSsh +import net.schmizz.sshj.transport.kex.DHGexSHA256 +import net.schmizz.sshj.transport.kex.ECDHNistP +import net.schmizz.sshj.transport.random.JCERandom +import net.schmizz.sshj.transport.random.SingletonRandomFactory +import net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile +import net.schmizz.sshj.userauth.keyprovider.PKCS5KeyFile +import net.schmizz.sshj.userauth.keyprovider.PKCS8KeyFile +import net.schmizz.sshj.userauth.keyprovider.PuTTYKeyFile +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.slf4j.Logger +import org.slf4j.Marker + +fun setUpBouncyCastleForSshj() { + // Replace the Android BC provider with the Java BouncyCastle provider since the former does + // not include all the required algorithms. + // Note: This may affect crypto operations in other parts of the application. + val bcIndex = + Security.getProviders().indexOfFirst { it.name == BouncyCastleProvider.PROVIDER_NAME } + if (bcIndex == -1) { + // No Android BC found, install Java BC at lowest priority. + Security.addProvider(BouncyCastleProvider()) + } else { + // Replace Android BC with Java BC, inserted at the same position. + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) + // May be needed on Android Pie+ as per https://stackoverflow.com/a/57897224/297261 + runCatching { Class.forName("sun.security.jca.Providers") } + Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1) + } + logcat("setUpBouncyCastleForSshj") { + "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" + } + // Prevent sshj from forwarding all cryptographic operations to BC. + SecurityUtils.setRegisterBouncyCastle(false) + SecurityUtils.setSecurityProvider(null) +} + +private abstract class AbstractLogger(private val name: String) : Logger { + + abstract fun t(message: String, t: Throwable? = null, vararg args: Any?) + abstract fun d(message: String, t: Throwable? = null, vararg args: Any?) + abstract fun i(message: String, t: Throwable? = null, vararg args: Any?) + abstract fun w(message: String, t: Throwable? = null, vararg args: Any?) + abstract fun e(message: String, t: Throwable? = null, vararg args: Any?) + + override fun getName() = name + + override fun isTraceEnabled(marker: Marker?): Boolean = isTraceEnabled + override fun isDebugEnabled(marker: Marker?): Boolean = isDebugEnabled + override fun isInfoEnabled(marker: Marker?): Boolean = isInfoEnabled + override fun isWarnEnabled(marker: Marker?): Boolean = isWarnEnabled + override fun isErrorEnabled(marker: Marker?): Boolean = isErrorEnabled + + override fun trace(msg: String) = t(msg) + override fun trace(format: String, arg: Any?) = t(format, null, arg) + override fun trace(format: String, arg1: Any?, arg2: Any?) = t(format, null, arg1, arg2) + override fun trace(format: String, vararg arguments: Any?) = t(format, null, *arguments) + override fun trace(msg: String, t: Throwable?) = t(msg, t) + override fun trace(marker: Marker, msg: String) = trace(msg) + override fun trace(marker: Marker?, format: String, arg: Any?) = trace(format, arg) + override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + trace(format, arg1, arg2) + + override fun trace(marker: Marker?, format: String, vararg arguments: Any?) = + trace(format, *arguments) + + override fun trace(marker: Marker?, msg: String, t: Throwable?) = trace(msg, t) + + override fun debug(msg: String) = d(msg) + override fun debug(format: String, arg: Any?) = d(format, null, arg) + override fun debug(format: String, arg1: Any?, arg2: Any?) = d(format, null, arg1, arg2) + override fun debug(format: String, vararg arguments: Any?) = d(format, null, *arguments) + override fun debug(msg: String, t: Throwable?) = d(msg, t) + override fun debug(marker: Marker, msg: String) = debug(msg) + override fun debug(marker: Marker?, format: String, arg: Any?) = debug(format, arg) + override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + debug(format, arg1, arg2) + + override fun debug(marker: Marker?, format: String, vararg arguments: Any?) = + debug(format, *arguments) + + override fun debug(marker: Marker?, msg: String, t: Throwable?) = debug(msg, t) + + override fun info(msg: String) = i(msg) + override fun info(format: String, arg: Any?) = i(format, null, arg) + override fun info(format: String, arg1: Any?, arg2: Any?) = i(format, null, arg1, arg2) + override fun info(format: String, vararg arguments: Any?) = i(format, null, *arguments) + override fun info(msg: String, t: Throwable?) = i(msg, t) + override fun info(marker: Marker, msg: String) = info(msg) + override fun info(marker: Marker?, format: String, arg: Any?) = info(format, arg) + override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + info(format, arg1, arg2) + + override fun info(marker: Marker?, format: String, vararg arguments: Any?) = + info(format, *arguments) + + override fun info(marker: Marker?, msg: String, t: Throwable?) = info(msg, t) + + override fun warn(msg: String) = w(msg) + override fun warn(format: String, arg: Any?) = w(format, null, arg) + override fun warn(format: String, arg1: Any?, arg2: Any?) = w(format, null, arg1, arg2) + override fun warn(format: String, vararg arguments: Any?) = w(format, null, *arguments) + override fun warn(msg: String, t: Throwable?) = w(msg, t) + override fun warn(marker: Marker, msg: String) = warn(msg) + override fun warn(marker: Marker?, format: String, arg: Any?) = warn(format, arg) + override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + warn(format, arg1, arg2) + + override fun warn(marker: Marker?, format: String, vararg arguments: Any?) = + warn(format, *arguments) + + override fun warn(marker: Marker?, msg: String, t: Throwable?) = warn(msg, t) + + override fun error(msg: String) = e(msg) + override fun error(format: String, arg: Any?) = e(format, null, arg) + override fun error(format: String, arg1: Any?, arg2: Any?) = e(format, null, arg1, arg2) + override fun error(format: String, vararg arguments: Any?) = e(format, null, *arguments) + override fun error(msg: String, t: Throwable?) = e(msg, t) + override fun error(marker: Marker, msg: String) = error(msg) + override fun error(marker: Marker?, format: String, arg: Any?) = error(format, arg) + override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + error(format, arg1, arg2) + + override fun error(marker: Marker?, format: String, vararg arguments: Any?) = + error(format, *arguments) + + override fun error(marker: Marker?, msg: String, t: Throwable?) = error(msg, t) +} + +object LogcatLoggerFactory : LoggerFactory { + private class LogcatLogger(name: String) : AbstractLogger(name) { + + override fun isTraceEnabled() = true + override fun isDebugEnabled() = true + override fun isInfoEnabled() = true + override fun isWarnEnabled() = true + override fun isErrorEnabled() = true + + // Replace slf4j's "{}" format string style with standard Java's "%s". + // The supposedly redundant escape on the } is not redundant. + @Suppress("RegExpRedundantEscape") + private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s") + + override fun t(message: String, t: Throwable?, vararg args: Any?) { + logcat(name, VERBOSE) { message.fix().format(*args) + (t?.asLog() ?: "") } + } + + override fun d(message: String, t: Throwable?, vararg args: Any?) { + logcat(name) { message.fix().format(*args) + (t?.asLog() ?: "") } + } + + override fun i(message: String, t: Throwable?, vararg args: Any?) { + logcat(name, INFO) { message.fix().format(*args) + (t?.asLog() ?: "") } + } + + override fun w(message: String, t: Throwable?, vararg args: Any?) { + logcat(name, WARN) { message.fix().format(*args) + (t?.asLog() ?: "") } + } + + override fun e(message: String, t: Throwable?, vararg args: Any?) { + logcat(name, ERROR) { message.fix().format(*args) + (t?.asLog() ?: "") } + } + } + + override fun getLogger(name: String): Logger { + return LogcatLogger(name) + } + + override fun getLogger(clazz: Class<*>): Logger { + return LogcatLogger(clazz.name) + } +} + +class SshjConfig : ConfigImpl() { + + init { + loggerFactory = LogcatLoggerFactory + keepAliveProvider = KeepAliveProvider.HEARTBEAT + version = "OpenSSH_8.2p1 Ubuntu-4ubuntu0.1" + + initKeyExchangeFactories() + initKeyAlgorithms() + initRandomFactory() + initFileKeyProviderFactories() + initCipherFactories() + initCompressionFactories() + initMACFactories() + } + + private fun initKeyExchangeFactories() { + keyExchangeFactories = + listOf( + Curve25519SHA256.Factory(), + FactoryLibSsh(), + ECDHNistP.Factory521(), + ECDHNistP.Factory384(), + ECDHNistP.Factory256(), + DHGexSHA256.Factory(), + // Sends "ext-info-c" with the list of key exchange algorithms. This is needed to + // get + // rsa-sha2-* key types to work with some servers (e.g. GitHub). + ExtInfoClientFactory(), + ) + } + + private fun initKeyAlgorithms() { + keyAlgorithms = + listOf( + KeyAlgorithms.SSHRSACertV01(), + KeyAlgorithms.EdDSA25519(), + KeyAlgorithms.ECDSASHANistp521(), + KeyAlgorithms.ECDSASHANistp384(), + KeyAlgorithms.ECDSASHANistp256(), + KeyAlgorithms.RSASHA512(), + KeyAlgorithms.RSASHA256(), + KeyAlgorithms.SSHRSA(), + ) + .map { OpenKeychainWrappedKeyAlgorithmFactory(it) } + } + + private fun initRandomFactory() { + randomFactory = SingletonRandomFactory(JCERandom.Factory()) + } + + private fun initFileKeyProviderFactories() { + fileKeyProviderFactories = + listOf( + OpenSSHKeyV1KeyFile.Factory(), + PKCS8KeyFile.Factory(), + PKCS5KeyFile.Factory(), + OpenSSHKeyFile.Factory(), + PuTTYKeyFile.Factory(), + ) + } + + private fun initCipherFactories() { + cipherFactories = + listOf( + GcmCiphers.AES128GCM(), + GcmCiphers.AES256GCM(), + BlockCiphers.AES256CTR(), + BlockCiphers.AES192CTR(), + BlockCiphers.AES128CTR(), + ) + } + + private fun initMACFactories() { + macFactories = + listOf( + Macs.HMACSHA2512Etm(), + Macs.HMACSHA2256Etm(), + Macs.HMACSHA2512(), + Macs.HMACSHA2256(), + ) + } + + private fun initCompressionFactories() { + compressionFactories = + listOf( + NoneCompression.Factory(), + ) + } +} diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt new file mode 100644 index 00000000..b7f0542f --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt @@ -0,0 +1,218 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.git.sshj + +import android.util.Base64 +import app.passwordstore.util.git.operation.CredentialFinder +import app.passwordstore.util.settings.AuthMode +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.runCatching +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.security.PublicKey +import java.util.Collections +import java.util.concurrent.TimeUnit +import kotlin.coroutines.Continuation +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import logcat.LogPriority.WARN +import logcat.logcat +import net.schmizz.sshj.SSHClient +import net.schmizz.sshj.common.Buffer.PlainBuffer +import net.schmizz.sshj.common.DisconnectReason +import net.schmizz.sshj.common.SSHException +import net.schmizz.sshj.common.SSHRuntimeException +import net.schmizz.sshj.common.SecurityUtils +import net.schmizz.sshj.connection.channel.direct.Session +import net.schmizz.sshj.transport.verification.FingerprintVerifier +import net.schmizz.sshj.transport.verification.HostKeyVerifier +import net.schmizz.sshj.userauth.method.AuthPassword +import net.schmizz.sshj.userauth.method.AuthPublickey +import net.schmizz.sshj.userauth.password.PasswordFinder +import net.schmizz.sshj.userauth.password.Resource +import org.eclipse.jgit.transport.CredentialsProvider +import org.eclipse.jgit.transport.RemoteSession +import org.eclipse.jgit.transport.SshSessionFactory +import org.eclipse.jgit.transport.URIish +import org.eclipse.jgit.util.FS + +sealed class SshAuthMethod(val activity: ContinuationContainerActivity) { + class Password(activity: ContinuationContainerActivity) : SshAuthMethod(activity) + class SshKey(activity: ContinuationContainerActivity) : SshAuthMethod(activity) + class OpenKeychain(activity: ContinuationContainerActivity) : SshAuthMethod(activity) +} + +abstract class InteractivePasswordFinder : PasswordFinder { + + private var isRetry = false + + abstract fun askForPassword(cont: Continuation, isRetry: Boolean) + + final override fun reqPassword(resource: Resource<*>?): CharArray { + val password = + runBlocking(Dispatchers.Main) { + suspendCoroutine { cont -> askForPassword(cont, isRetry) } + } + isRetry = true + return password?.toCharArray() ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER) + } + + final override fun shouldRetry(resource: Resource<*>?) = true +} + +class SshjSessionFactory(private val authMethod: SshAuthMethod, private val hostKeyFile: File) : + SshSessionFactory() { + + private var currentSession: SshjSession? = null + + override fun getSession( + uri: URIish, + credentialsProvider: CredentialsProvider?, + fs: FS?, + tms: Int + ): RemoteSession { + return currentSession + ?: SshjSession(uri, uri.user, authMethod, hostKeyFile).connect().also { + logcat { "New SSH connection created" } + currentSession = it + } + } + + fun close() { + currentSession?.close() + } +} + +private fun makeTofuHostKeyVerifier(hostKeyFile: File): HostKeyVerifier { + if (!hostKeyFile.exists()) { + return object : HostKeyVerifier { + override fun verify(hostname: String?, port: Int, key: PublicKey?): Boolean { + val digest = + runCatching { SecurityUtils.getMessageDigest("SHA-256") } + .getOrElse { e -> throw SSHRuntimeException(e) } + digest.update(PlainBuffer().putPublicKey(key).compactData) + val digestData = digest.digest() + val hostKeyEntry = "SHA256:${Base64.encodeToString(digestData, Base64.NO_WRAP)}" + logcat(SshjSessionFactory::class.java.simpleName) { + "Trusting host key on first use: $hostKeyEntry" + } + hostKeyFile.writeText(hostKeyEntry) + return true + } + + override fun findExistingAlgorithms(hostname: String?, port: Int): MutableList { + return Collections.emptyList() + } + } + } else { + val hostKeyEntry = hostKeyFile.readText() + logcat(SshjSessionFactory::class.java.simpleName) { "Pinned host key: $hostKeyEntry" } + return FingerprintVerifier.getInstance(hostKeyEntry) + } +} + +private class SshjSession( + uri: URIish, + private val username: String, + private val authMethod: SshAuthMethod, + private val hostKeyFile: File +) : RemoteSession { + + private lateinit var ssh: SSHClient + private var currentCommand: Session? = null + + private val uri = + if (uri.host.contains('@')) { + // URIish's String constructor cannot handle '@' in the user part of the URI and the URL + // constructor can't be used since Java's URL does not recognize the ssh scheme. We thus + // need to patch everything up ourselves. + logcat { "Before fixup: user=${uri.user}, host=${uri.host}" } + val userPlusHost = "${uri.user}@${uri.host}" + val realUser = userPlusHost.substringBeforeLast('@') + val realHost = userPlusHost.substringAfterLast('@') + uri.setUser(realUser).setHost(realHost).also { + logcat { "After fixup: user=${it.user}, host=${it.host}" } + } + } else { + uri + } + + fun connect(): SshjSession { + ssh = SSHClient(SshjConfig()) + ssh.addHostKeyVerifier(makeTofuHostKeyVerifier(hostKeyFile)) + ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22) + if (!ssh.isConnected) throw IOException() + val passwordAuth = AuthPassword(CredentialFinder(authMethod.activity, AuthMode.Password)) + when (authMethod) { + is SshAuthMethod.Password -> { + ssh.auth(username, passwordAuth) + } + is SshAuthMethod.SshKey -> { + val pubkeyAuth = + AuthPublickey(SshKey.provide(ssh, CredentialFinder(authMethod.activity, AuthMode.SshKey))) + ssh.auth(username, pubkeyAuth, passwordAuth) + } + is SshAuthMethod.OpenKeychain -> { + runBlocking { + OpenKeychainKeyProvider.prepareAndUse(authMethod.activity) { provider -> + val openKeychainAuth = AuthPublickey(provider) + ssh.auth(username, openKeychainAuth, passwordAuth) + } + } + } + } + return this + } + + override fun exec(commandName: String?, timeout: Int): Process { + if (currentCommand != null) { + logcat(WARN) { "Killing old command" } + disconnect() + } + val session = ssh.startSession() + currentCommand = session + return SshjProcess(session.exec(commandName), timeout.toLong()) + } + + /** + * Kills the current command if one is running and returns the session into a state where `exec` + * can be called. + * + * Note that this does *not* disconnect the session. Unfortunately, the function has to be called + * `disconnect` to override the corresponding abstract function in `RemoteSession`. + */ + override fun disconnect() { + currentCommand?.close() + currentCommand = null + } + + fun close() { + disconnect() + ssh.close() + } +} + +private class SshjProcess(private val command: Session.Command, private val timeout: Long) : + Process() { + + override fun waitFor(): Int { + command.join(timeout, TimeUnit.SECONDS) + command.close() + return exitValue() + } + + override fun destroy() = command.close() + + override fun getOutputStream(): OutputStream = command.outputStream + + override fun getErrorStream(): InputStream = command.errorStream + + override fun exitValue(): Int = command.exitStatus + + override fun getInputStream(): InputStream = command.inputStream +} diff --git a/app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt b/app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt new file mode 100644 index 00000000..633c3904 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt @@ -0,0 +1,71 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.proxy + +import app.passwordstore.util.settings.GitSettings +import java.io.IOException +import java.net.Authenticator +import java.net.InetSocketAddress +import java.net.PasswordAuthentication +import java.net.Proxy +import java.net.ProxySelector +import java.net.SocketAddress +import java.net.URI +import javax.inject.Inject +import javax.inject.Singleton + +/** Utility class for [Proxy] handling. */ +@Singleton +class ProxyUtils @Inject constructor(private val gitSettings: GitSettings) { + + /** Set the default [Proxy] and [Authenticator] for the app based on user provided settings. */ + fun setDefaultProxy() { + ProxySelector.setDefault( + object : ProxySelector() { + override fun select(uri: URI?): MutableList { + val host = gitSettings.proxyHost + val port = gitSettings.proxyPort + return if (host == null || port == -1) { + mutableListOf(Proxy.NO_PROXY) + } else { + mutableListOf(Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(host, port))) + } + } + + override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) { + if (uri == null || sa == null || ioe == null) { + throw IllegalArgumentException("Arguments can't be null.") + } + } + } + ) + val user = gitSettings.proxyUsername ?: "" + val password = gitSettings.proxyPassword ?: "" + if (user.isEmpty() || password.isEmpty()) { + System.clearProperty(HTTP_PROXY_USER_PROPERTY) + System.clearProperty(HTTP_PROXY_PASSWORD_PROPERTY) + } else { + System.setProperty(HTTP_PROXY_USER_PROPERTY, user) + System.setProperty(HTTP_PROXY_PASSWORD_PROPERTY, password) + } + Authenticator.setDefault( + object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication? { + return if (requestorType == RequestorType.PROXY) { + PasswordAuthentication(user, password.toCharArray()) + } else { + null + } + } + } + ) + } + + companion object { + private const val HTTP_PROXY_USER_PROPERTY = "http.proxyUser" + private const val HTTP_PROXY_PASSWORD_PROPERTY = "http.proxyPassword" + } +} diff --git a/app/src/main/java/app/passwordstore/util/services/ClipboardService.kt b/app/src/main/java/app/passwordstore/util/services/ClipboardService.kt new file mode 100644 index 00000000..3d57e29b --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/services/ClipboardService.kt @@ -0,0 +1,206 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.services + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.ClipData +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.core.content.getSystemService +import app.passwordstore.R +import app.passwordstore.util.extensions.clipboard +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.settings.PreferenceKeys +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import logcat.logcat + +class ClipboardService : Service() { + + private val scope = CoroutineScope(Job() + Dispatchers.Main) + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent != null) { + when (intent.action) { + ACTION_CLEAR -> { + clearClipboard() + stopForeground(true) + stopSelf() + return super.onStartCommand(intent, flags, startId) + } + ACTION_START -> { + val time = intent.getIntExtra(EXTRA_NOTIFICATION_TIME, 45) + + if (time == 0) { + stopSelf() + } + + createNotification(time) + scope.launch { + withContext(Dispatchers.IO) { startTimer(time) } + withContext(Dispatchers.Main) { + clearClipboard() + stopForeground(true) + stopSelf() + } + } + return START_NOT_STICKY + } + } + } + + return super.onStartCommand(intent, flags, startId) + } + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } + + private fun clearClipboard() { + val deepClear = sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false) + val clipboard = clipboard + + if (clipboard != null) { + scope.launch { + logcat { "Clearing the clipboard" } + val clip = ClipData.newPlainText("pgp_handler_result_pm", "") + clipboard.setPrimaryClip(clip) + if (deepClear) { + withContext(Dispatchers.IO) { + repeat(CLIPBOARD_CLEAR_COUNT) { + val count = (it * 500).toString() + clipboard.setPrimaryClip(ClipData.newPlainText(count, count)) + } + } + } + } + } else { + logcat { "Cannot get clipboard manager service" } + } + } + + private suspend fun startTimer(showTime: Int) { + var current = 0 + while (scope.isActive && current < showTime) { + // Block for 1s or until cancel is signalled + current++ + delay(1000) + } + } + + private fun createNotification(clearTime: Int) { + val clearTimeMs = clearTime * 1000L + val clearIntent = Intent(this, ClipboardService::class.java).apply { action = ACTION_CLEAR } + val pendingIntent = + if (Build.VERSION.SDK_INT >= 26) { + PendingIntent.getForegroundService( + this, + 0, + clearIntent, + if (Build.VERSION.SDK_INT >= 31) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + ) + } else { + PendingIntent.getService( + this, + 0, + clearIntent, + if (Build.VERSION.SDK_INT >= 31) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + }, + ) + } + val notification = + if (Build.VERSION.SDK_INT <= 23) { + createNotificationApi23(pendingIntent) + } else { + createNotificationApi24(pendingIntent, clearTimeMs) + } + + createNotificationChannel() + startForeground(1, notification) + } + + private fun createNotificationApi23(pendingIntent: PendingIntent): Notification { + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.tap_clear_clipboard)) + .setSmallIcon(R.drawable.ic_action_secure_24dp) + .setContentIntent(pendingIntent) + .setUsesChronometer(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } + + @RequiresApi(24) + private fun createNotificationApi24( + pendingIntent: PendingIntent, + clearTimeMs: Long + ): Notification { + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.tap_clear_clipboard)) + .setSmallIcon(R.drawable.ic_action_secure_24dp) + .setContentIntent(pendingIntent) + .setUsesChronometer(true) + .setChronometerCountDown(true) + .setShowWhen(true) + .setWhen(System.currentTimeMillis() + clearTimeMs) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= 26) { + val serviceChannel = + NotificationChannel( + CHANNEL_ID, + getString(R.string.app_name), + NotificationManager.IMPORTANCE_LOW + ) + val manager = getSystemService() + if (manager != null) { + manager.createNotificationChannel(serviceChannel) + } else { + logcat { "Failed to create notification channel" } + } + } + } + + companion object { + + const val ACTION_START = "ACTION_START_CLIPBOARD_TIMER" + const val EXTRA_NOTIFICATION_TIME = "EXTRA_NOTIFICATION_TIME" + private const val ACTION_CLEAR = "ACTION_CLEAR_CLIPBOARD" + private const val CHANNEL_ID = "NotificationService" + // Newest Samsung phones now feature a history of up to 30 items. To err on the side of + // caution, + // push 35 fake ones. + private const val CLIPBOARD_CLEAR_COUNT = 35 + } +} diff --git a/app/src/main/java/app/passwordstore/util/services/OreoAutofillService.kt b/app/src/main/java/app/passwordstore/util/services/OreoAutofillService.kt new file mode 100644 index 00000000..54cbb3b5 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/services/OreoAutofillService.kt @@ -0,0 +1,172 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.services + +import android.content.Context +import android.os.Build +import android.os.CancellationSignal +import android.service.autofill.AutofillService +import android.service.autofill.FillCallback +import android.service.autofill.FillRequest +import android.service.autofill.FillResponse +import android.service.autofill.SaveCallback +import android.service.autofill.SaveRequest +import androidx.annotation.RequiresApi +import app.passwordstore.BuildConfig +import app.passwordstore.R +import app.passwordstore.ui.autofill.AutofillSaveActivity +import app.passwordstore.util.autofill.Api30AutofillResponseBuilder +import app.passwordstore.util.autofill.AutofillResponseBuilder +import app.passwordstore.util.extensions.getString +import app.passwordstore.util.extensions.hasFlag +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.settings.PreferenceKeys +import com.github.androidpasswordstore.autofillparser.AutofillScenario +import com.github.androidpasswordstore.autofillparser.Credentials +import com.github.androidpasswordstore.autofillparser.FillableForm +import com.github.androidpasswordstore.autofillparser.FixedSaveCallback +import com.github.androidpasswordstore.autofillparser.FormOrigin +import com.github.androidpasswordstore.autofillparser.cachePublicSuffixList +import com.github.androidpasswordstore.autofillparser.passwordValue +import com.github.androidpasswordstore.autofillparser.recoverNodes +import com.github.androidpasswordstore.autofillparser.usernameValue +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import logcat.LogPriority.ERROR +import logcat.logcat + +@RequiresApi(26) +@AndroidEntryPoint +class OreoAutofillService : AutofillService() { + + companion object { + + // TODO: Provide a user-configurable denylist + private val DENYLISTED_PACKAGES = + listOf( + BuildConfig.APPLICATION_ID, + "android", + "com.android.settings", + "com.android.settings.intelligence", + "com.android.systemui", + "com.oneplus.applocker", + "org.sufficientlysecure.keychain", + ) + + private const val DISABLE_AUTOFILL_DURATION_MS = 1000 * 60 * 60 * 24L + } + + @Inject lateinit var api30ResponseBuilderFactory: Api30AutofillResponseBuilder.Factory + @Inject lateinit var responseBuilderFactory: AutofillResponseBuilder.Factory + + override fun onCreate() { + super.onCreate() + cachePublicSuffixList(applicationContext) + } + + override fun onFillRequest( + request: FillRequest, + cancellationSignal: CancellationSignal, + callback: FillCallback + ) { + val structure = + request.fillContexts.lastOrNull()?.structure + ?: run { + callback.onSuccess(null) + return + } + if (structure.activityComponent.packageName in DENYLISTED_PACKAGES) { + if (Build.VERSION.SDK_INT >= 28) { + callback.onSuccess( + FillResponse.Builder().run { + disableAutofill(DISABLE_AUTOFILL_DURATION_MS) + build() + } + ) + } else { + callback.onSuccess(null) + } + return + } + val formToFill = + FillableForm.parseAssistStructure( + this, + structure, + isManualRequest = request.flags hasFlag FillRequest.FLAG_MANUAL_REQUEST, + getCustomSuffixes(), + ) + ?: run { + logcat { "Form cannot be filled" } + callback.onSuccess(null) + return + } + if (Build.VERSION.SDK_INT >= 30) { + api30ResponseBuilderFactory + .create(formToFill) + .fillCredentials(this, request.inlineSuggestionsRequest, callback) + } else { + responseBuilderFactory.create(formToFill).fillCredentials(this, callback) + } + } + + override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { + // SaveCallback's behavior and feature set differs based on both target and device SDK, so + // we replace it with a wrapper that works the same in all situations. + @Suppress("NAME_SHADOWING") val callback = FixedSaveCallback(this, callback) + val structure = + request.fillContexts.lastOrNull()?.structure + ?: run { + callback.onFailure(getString(R.string.oreo_autofill_save_app_not_supported)) + return + } + val clientState = + request.clientState + ?: run { + logcat(ERROR) { "Received save request without client state" } + callback.onFailure(getString(R.string.oreo_autofill_save_internal_error)) + return + } + val scenario = + AutofillScenario.fromClientState(clientState)?.recoverNodes(structure) + ?: run { + logcat(ERROR) { "Failed to recover client state or nodes from client state" } + callback.onFailure(getString(R.string.oreo_autofill_save_internal_error)) + return + } + val formOrigin = + FormOrigin.fromBundle(clientState) + ?: run { + logcat(ERROR) { "Failed to recover form origin from client state" } + callback.onFailure(getString(R.string.oreo_autofill_save_internal_error)) + return + } + + val username = scenario.usernameValue + val password = + scenario.passwordValue + ?: run { + callback.onFailure(getString(R.string.oreo_autofill_save_passwords_dont_match)) + return + } + callback.onSuccess( + AutofillSaveActivity.makeSaveIntentSender( + this, + credentials = Credentials(username, password, null), + formOrigin = formOrigin + ) + ) + } +} + +fun Context.getDefaultUsername() = + sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) + +fun Context.getCustomSuffixes(): Sequence { + return sharedPrefs + .getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) + ?.splitToSequence('\n') + ?.filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' } + ?: emptySequence() +} diff --git a/app/src/main/java/app/passwordstore/util/services/PasswordExportService.kt b/app/src/main/java/app/passwordstore/util/services/PasswordExportService.kt new file mode 100644 index 00000000..536006f6 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/services/PasswordExportService.kt @@ -0,0 +1,159 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.services + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.content.getSystemService +import androidx.documentfile.provider.DocumentFile +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Calendar +import java.util.TimeZone +import logcat.logcat + +class PasswordExportService : Service() { + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent != null) { + when (intent.action) { + ACTION_EXPORT_PASSWORD -> { + val uri = intent.getParcelableExtra("uri") + if (uri != null) { + val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri) + + if (targetDirectory != null) { + createNotification() + exportPasswords(targetDirectory) + stopSelf() + return START_NOT_STICKY + } + } + } + } + } + return super.onStartCommand(intent, flags, startId) + } + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + /** + * Exports passwords to the given directory. + * + * Recursively copies the existing password store to an external directory. + * + * @param targetDirectory directory to copy password directory to. + */ + private fun exportPasswords(targetDirectory: DocumentFile) { + + val repositoryDirectory = requireNotNull(PasswordRepository.getRepositoryDirectory()) + val sourcePassDir = DocumentFile.fromFile(repositoryDirectory) + + logcat { "Copying ${repositoryDirectory.path} to $targetDirectory" } + + val dateString = + if (Build.VERSION.SDK_INT >= 26) { + LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME) + } else { + String.format("%tFT% + if (file.isDirectory) { + // Create new directory and recurse + val newDir = targetDirectory.createDirectory(file.name!!) + copyDirToDir(file, newDir!!) + } else { + copyFileToDir(file, targetDirectory) + } + } + } + + private fun createNotification() { + createNotificationChannel() + + val notification = + NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.exporting_passwords)) + .setSmallIcon(R.drawable.ic_round_import_export) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + startForeground(2, notification) + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= 26) { + val serviceChannel = + NotificationChannel( + CHANNEL_ID, + getString(R.string.app_name), + NotificationManager.IMPORTANCE_LOW + ) + val manager = getSystemService() + if (manager != null) { + manager.createNotificationChannel(serviceChannel) + } else { + logcat { "Failed to create notification channel" } + } + } + } + + companion object { + + const val ACTION_EXPORT_PASSWORD = "ACTION_EXPORT_PASSWORD" + private const val CHANNEL_ID = "NotificationService" + } +} diff --git a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt new file mode 100644 index 00000000..1baf9640 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt @@ -0,0 +1,186 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.settings + +import android.content.SharedPreferences +import androidx.core.content.edit +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.injection.context.FilesDirPath +import app.passwordstore.injection.prefs.GitPreferences +import app.passwordstore.injection.prefs.ProxyPreferences +import app.passwordstore.injection.prefs.SettingsPreferences +import app.passwordstore.util.extensions.getString +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.runCatching +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton +import org.eclipse.jgit.transport.URIish + +enum class Protocol(val pref: String) { + Ssh("ssh://"), + Https("https://"), + ; + + companion object { + + private val map = values().associateBy(Protocol::pref) + fun fromString(type: String?): Protocol { + return map[type ?: return Ssh] + ?: throw IllegalArgumentException("$type is not a valid Protocol") + } + } +} + +enum class AuthMode(val pref: String) { + SshKey("ssh-key"), + Password("username/password"), + OpenKeychain("OpenKeychain"), + None("None"), + ; + + companion object { + + private val map = values().associateBy(AuthMode::pref) + fun fromString(type: String?): AuthMode { + return map[type ?: return SshKey] + ?: throw IllegalArgumentException("$type is not a valid AuthMode") + } + } +} + +@Singleton +class GitSettings +@Inject +constructor( + @SettingsPreferences private val settings: SharedPreferences, + @GitPreferences private val encryptedSettings: SharedPreferences, + @ProxyPreferences private val proxySettings: SharedPreferences, + @FilesDirPath private val filesDirPath: String, +) { + + private val hostKeyPath = "$filesDirPath/.host_key" + var authMode + get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH)) + private set(value) { + settings.edit { putString(PreferenceKeys.GIT_REMOTE_AUTH, value.pref) } + } + var url + get() = settings.getString(PreferenceKeys.GIT_REMOTE_URL) + private set(value) { + require(value != null) + if (value == url) return + settings.edit { putString(PreferenceKeys.GIT_REMOTE_URL, value) } + if (PasswordRepository.isInitialized) PasswordRepository.addRemote("origin", value, true) + // When the server changes, remote password, multiplexing support and host key file + // should be deleted/reset. + useMultiplexing = true + encryptedSettings.edit { remove(PreferenceKeys.HTTPS_PASSWORD) } + clearSavedHostKey() + } + var authorName + get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME) ?: "" + set(value) { + settings.edit { putString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME, value) } + } + var authorEmail + get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL) ?: "" + set(value) { + settings.edit { putString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, value) } + } + var branch + get() = settings.getString(PreferenceKeys.GIT_BRANCH_NAME) ?: DEFAULT_BRANCH + private set(value) { + settings.edit { putString(PreferenceKeys.GIT_BRANCH_NAME, value) } + } + var useMultiplexing + get() = settings.getBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, true) + set(value) { + settings.edit { putBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, value) } + } + var proxyHost + get() = proxySettings.getString(PreferenceKeys.PROXY_HOST) + set(value) { + proxySettings.edit { putString(PreferenceKeys.PROXY_HOST, value) } + } + var proxyPort + get() = proxySettings.getInt(PreferenceKeys.PROXY_PORT, -1) + set(value) { + proxySettings.edit { putInt(PreferenceKeys.PROXY_PORT, value) } + } + var proxyUsername + get() = settings.getString(PreferenceKeys.PROXY_USERNAME) + set(value) { + proxySettings.edit { putString(PreferenceKeys.PROXY_USERNAME, value) } + } + var proxyPassword + get() = proxySettings.getString(PreferenceKeys.PROXY_PASSWORD) + set(value) { + proxySettings.edit { putString(PreferenceKeys.PROXY_PASSWORD, value) } + } + var rebaseOnPull + get() = settings.getBoolean(PreferenceKeys.REBASE_ON_PULL, true) + set(value) { + settings.edit { putBoolean(PreferenceKeys.REBASE_ON_PULL, value) } + } + + sealed class UpdateConnectionSettingsResult { + class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult() + class AuthModeMismatch(val newProtocol: Protocol, val validModes: List) : + UpdateConnectionSettingsResult() + object Valid : UpdateConnectionSettingsResult() + object FailedToParseUrl : UpdateConnectionSettingsResult() + } + + fun updateConnectionSettingsIfValid( + newAuthMode: AuthMode, + newUrl: String, + newBranch: String + ): UpdateConnectionSettingsResult { + val parsedUrl = + runCatching { URIish(newUrl) } + .getOrElse { + return UpdateConnectionSettingsResult.FailedToParseUrl + } + val newProtocol = + when (parsedUrl.scheme) { + in listOf("http", "https") -> Protocol.Https + in listOf("ssh", null) -> Protocol.Ssh + else -> return UpdateConnectionSettingsResult.FailedToParseUrl + } + if ( + (newAuthMode != AuthMode.None && newProtocol != Protocol.Https) && + parsedUrl.user.isNullOrBlank() + ) + return UpdateConnectionSettingsResult.MissingUsername(newProtocol) + val validHttpsAuth = listOf(AuthMode.None, AuthMode.Password) + val validSshAuth = listOf(AuthMode.OpenKeychain, AuthMode.Password, AuthMode.SshKey) + when { + newProtocol == Protocol.Https && newAuthMode !in validHttpsAuth -> { + return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validHttpsAuth) + } + newProtocol == Protocol.Ssh && newAuthMode !in validSshAuth -> { + return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validSshAuth) + } + } + + url = newUrl + authMode = newAuthMode + branch = newBranch + return UpdateConnectionSettingsResult.Valid + } + + /** Deletes a previously saved SSH host key */ + fun clearSavedHostKey() { + File(hostKeyPath).delete() + } + + /** Returns true if a host key was previously saved */ + fun hasSavedHostKey(): Boolean = File(hostKeyPath).exists() + + companion object { + private const val DEFAULT_BRANCH = "master" + } +} diff --git a/app/src/main/java/app/passwordstore/util/settings/Migrations.kt b/app/src/main/java/app/passwordstore/util/settings/Migrations.kt new file mode 100644 index 00000000..859e6360 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/settings/Migrations.kt @@ -0,0 +1,154 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +@file:Suppress("DEPRECATION") + +package app.passwordstore.util.settings + +import android.content.SharedPreferences +import androidx.core.content.edit +import app.passwordstore.util.extensions.getString +import app.passwordstore.util.git.sshj.SshKey +import com.github.michaelbull.result.get +import com.github.michaelbull.result.runCatching +import java.io.File +import java.net.URI +import logcat.LogPriority.ERROR +import logcat.LogPriority.INFO +import logcat.logcat + +private const val TAG = "Migrations" + +fun runMigrations(filesDirPath: String, sharedPrefs: SharedPreferences, gitSettings: GitSettings) { + migrateToGitUrlBasedConfig(sharedPrefs, gitSettings) + migrateToHideAll(sharedPrefs) + migrateToSshKey(filesDirPath, sharedPrefs) + migrateToClipboardHistory(sharedPrefs) + migrateToDiceware(sharedPrefs) + removeExternalStorageProperties(sharedPrefs) +} + +private fun migrateToGitUrlBasedConfig(sharedPrefs: SharedPreferences, gitSettings: GitSettings) { + val serverHostname = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_SERVER) ?: return + logcat(TAG, INFO) { "Migrating to URL-based Git config" } + val serverPort = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PORT) ?: "" + val serverUser = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_USERNAME) ?: "" + val serverPath = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_LOCATION) ?: "" + val protocol = Protocol.fromString(sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PROTOCOL)) + + // Whether we need the leading ssh:// depends on the use of a custom port. + val hostnamePart = serverHostname.removePrefix("ssh://") + val url = + when (protocol) { + Protocol.Ssh -> { + val userPart = if (serverUser.isEmpty()) "" else "${serverUser.trimEnd('@')}@" + val portPart = if (serverPort == "22" || serverPort.isEmpty()) "" else ":$serverPort" + if (portPart.isEmpty()) { + "$userPart$hostnamePart:$serverPath" + } else { + // Only absolute paths are supported with custom ports. + if (!serverPath.startsWith('/')) { + null + } else { + // We have to specify the ssh scheme as this is the only way to pass a custom + // port. + "ssh://$userPart$hostnamePart$portPart$serverPath" + } + } + } + Protocol.Https -> { + val portPart = if (serverPort == "443" || serverPort.isEmpty()) "" else ":$serverPort" + val pathPart = serverPath.trimStart('/', ':') + val urlWithFreeEntryScheme = "$hostnamePart$portPart/$pathPart" + val url = + when { + urlWithFreeEntryScheme.startsWith("https://") -> urlWithFreeEntryScheme + urlWithFreeEntryScheme.startsWith("http://") -> + urlWithFreeEntryScheme.replaceFirst("http", "https") + else -> "https://$urlWithFreeEntryScheme" + } + runCatching { if (URI(url).rawAuthority != null) url else null }.get() + } + } + + sharedPrefs.edit { + remove(PreferenceKeys.GIT_REMOTE_LOCATION) + remove(PreferenceKeys.GIT_REMOTE_PORT) + remove(PreferenceKeys.GIT_REMOTE_SERVER) + remove(PreferenceKeys.GIT_REMOTE_USERNAME) + remove(PreferenceKeys.GIT_REMOTE_PROTOCOL) + } + if ( + url == null || + gitSettings.updateConnectionSettingsIfValid( + newAuthMode = gitSettings.authMode, + newUrl = url, + newBranch = gitSettings.branch + ) != GitSettings.UpdateConnectionSettingsResult.Valid + ) { + logcat(TAG, ERROR) { "Failed to migrate to URL-based Git config, generated URL is invalid" } + } +} + +private fun migrateToHideAll(sharedPrefs: SharedPreferences) { + sharedPrefs.all[PreferenceKeys.SHOW_HIDDEN_FOLDERS] ?: return + val isHidden = sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false) + sharedPrefs.edit { + remove(PreferenceKeys.SHOW_HIDDEN_FOLDERS) + putBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, isHidden) + } +} + +private fun migrateToSshKey(filesDirPath: String, sharedPrefs: SharedPreferences) { + val privateKeyFile = File(filesDirPath, ".ssh_key") + if ( + sharedPrefs.contains(PreferenceKeys.USE_GENERATED_KEY) && + !SshKey.exists && + privateKeyFile.exists() + ) { + // Currently uses a private key imported or generated with an old version of Password Store. + // Generated keys come with a public key which the user should still be able to view after + // the migration (not possible for regular imported keys), hence the special case. + val isGeneratedKey = sharedPrefs.getBoolean(PreferenceKeys.USE_GENERATED_KEY, false) + SshKey.useLegacyKey(isGeneratedKey) + sharedPrefs.edit { remove(PreferenceKeys.USE_GENERATED_KEY) } + } +} + +private fun migrateToClipboardHistory(sharedPrefs: SharedPreferences) { + if (sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X)) { + sharedPrefs.edit { + putBoolean( + PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, + sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, false) + ) + remove(PreferenceKeys.CLEAR_CLIPBOARD_20X) + } + } +} + +private fun migrateToDiceware(sharedPrefs: SharedPreferences) { + if (sharedPrefs.contains(PreferenceKeys.PREF_KEY_PWGEN_TYPE)) { + sharedPrefs.edit { + if (sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd") { + putString(PreferenceKeys.PREF_KEY_PWGEN_TYPE, "diceware") + } + } + } +} + +private fun removeExternalStorageProperties(prefs: SharedPreferences) { + logcat(TAG, INFO) { "Removing preferences related to external storage" } + prefs.edit { + if (prefs.contains(PreferenceKeys.GIT_EXTERNAL)) { + if (prefs.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) { + putBoolean(PreferenceKeys.GIT_EXTERNAL_MIGRATED, true) + } + remove(PreferenceKeys.GIT_EXTERNAL) + } + if (prefs.contains(PreferenceKeys.GIT_EXTERNAL_REPO)) { + remove(PreferenceKeys.GIT_EXTERNAL_REPO) + } + } +} diff --git a/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt b/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt new file mode 100644 index 00000000..61e4b118 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt @@ -0,0 +1,53 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.settings + +import android.content.Context +import android.content.SharedPreferences +import app.passwordstore.Application +import app.passwordstore.data.password.PasswordItem +import app.passwordstore.util.extensions.base64 +import app.passwordstore.util.extensions.getString + +enum class PasswordSortOrder(val comparator: java.util.Comparator) { + FOLDER_FIRST( + Comparator { p1: PasswordItem, p2: PasswordItem -> + (p1.type + p1.name).compareTo(p2.type + p2.name, ignoreCase = true) + } + ), + INDEPENDENT( + Comparator { p1: PasswordItem, p2: PasswordItem -> + p1.name.compareTo(p2.name, ignoreCase = true) + } + ), + RECENTLY_USED( + Comparator { p1: PasswordItem, p2: PasswordItem -> + val recentHistory = + Application.instance.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) + val timeP1 = recentHistory.getString(p1.file.absolutePath.base64()) + val timeP2 = recentHistory.getString(p2.file.absolutePath.base64()) + when { + timeP1 != null && timeP2 != null -> timeP2.compareTo(timeP1) + timeP1 != null && timeP2 == null -> return@Comparator -1 + timeP1 == null && timeP2 != null -> return@Comparator 1 + else -> p1.name.compareTo(p2.name, ignoreCase = true) + } + } + ), + FILE_FIRST( + Comparator { p1: PasswordItem, p2: PasswordItem -> + (p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true) + } + ); + + companion object { + + @JvmStatic + fun getSortOrder(settings: SharedPreferences): PasswordSortOrder { + return valueOf(settings.getString(PreferenceKeys.SORT_ORDER) ?: FOLDER_FIRST.name) + } + } +} diff --git a/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt b/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt new file mode 100644 index 00000000..16edb4c7 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt @@ -0,0 +1,88 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.settings + +object PreferenceKeys { + + const val APP_THEME = "app_theme" + const val AUTOFILL_ENABLE = "autofill_enable" + const val BIOMETRIC_AUTH = "biometric_auth" + @Deprecated( + message = "Use CLEAR_CLIPBOARD_HISTORY instead", + replaceWith = ReplaceWith("PreferenceKeys.CLEAR_CLIPBOARD_HISTORY"), + ) + const val CLEAR_CLIPBOARD_20X = "clear_clipboard_20x" + const val CLEAR_CLIPBOARD_HISTORY = "clear_clipboard_history" + const val CLEAR_SAVED_PASS = "clear_saved_pass" + const val COPY_ON_DECRYPT = "copy_on_decrypt" + const val ENABLE_DEBUG_LOGGING = "enable_debug_logging" + const val EXPORT_PASSWORDS = "export_passwords" + const val FILTER_RECURSIVELY = "filter_recursively" + const val GENERAL_SHOW_TIME = "general_show_time" + const val GIT_CONFIG = "git_config" + const val GIT_CONFIG_AUTHOR_EMAIL = "git_config_user_email" + const val GIT_CONFIG_AUTHOR_NAME = "git_config_user_name" + @Deprecated(message = "We're removing support for external storage") + const val GIT_EXTERNAL = "git_external" + @Deprecated(message = "We're removing support for external storage") + const val GIT_EXTERNAL_REPO = "git_external_repo" + const val GIT_EXTERNAL_MIGRATED = "git_external_migrated" + const val GIT_REMOTE_AUTH = "git_remote_auth" + const val GIT_REMOTE_KEY_TYPE = "git_remote_key_type" + + @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_LOCATION = "git_remote_location" + const val GIT_REMOTE_USE_MULTIPLEXING = "git_remote_use_multiplexing" + + @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_PORT = "git_remote_port" + + @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_PROTOCOL = "git_remote_protocol" + const val GIT_DELETE_REPO = "git_delete_repo" + + @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_SERVER = "git_remote_server" + const val GIT_REMOTE_URL = "git_remote_url" + + @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_USERNAME = "git_remote_username" + const val GIT_SERVER_INFO = "git_server_info" + const val GIT_BRANCH_NAME = "git_branch" + const val HTTPS_PASSWORD = "https_password" + const val LENGTH = "length" + const val OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES = "oreo_autofill_custom_public_suffixes" + const val OREO_AUTOFILL_DEFAULT_USERNAME = "oreo_autofill_default_username" + const val OREO_AUTOFILL_DIRECTORY_STRUCTURE = "oreo_autofill_directory_structure" + const val PREF_KEY_PWGEN_TYPE = "pref_key_pwgen_type" + const val REPOSITORY_INITIALIZED = "repository_initialized" + const val REPO_CHANGED = "repo_changed" + const val SEARCH_ON_START = "search_on_start" + + @Deprecated( + message = "Use SHOW_HIDDEN_CONTENTS instead", + replaceWith = ReplaceWith("PreferenceKeys.SHOW_HIDDEN_CONTENTS") + ) + const val SHOW_HIDDEN_FOLDERS = "show_hidden_folders" + const val SHOW_HIDDEN_CONTENTS = "show_hidden_contents" + const val SORT_ORDER = "sort_order" + const val SHOW_PASSWORD = "show_password" + const val SSH_KEY = "ssh_key" + const val SSH_KEYGEN = "ssh_keygen" + const val SSH_KEY_LOCAL_PASSPHRASE = "ssh_key_local_passphrase" + const val SSH_OPENKEYSTORE_CLEAR_KEY_ID = "ssh_openkeystore_clear_keyid" + const val SSH_OPENKEYSTORE_KEYID = "ssh_openkeystore_keyid" + const val SSH_SEE_KEY = "ssh_see_key" + + @Deprecated("To be used only in Migrations.kt") const val USE_GENERATED_KEY = "use_generated_key" + + const val PROXY_SETTINGS = "proxy_settings" + const val PROXY_HOST = "proxy_host" + const val PROXY_PORT = "proxy_port" + const val PROXY_USERNAME = "proxy_username" + const val PROXY_PASSWORD = "proxy_password" + + const val REBASE_ON_PULL = "rebase_on_pull" + + const val DICEWARE_SEPARATOR = "diceware_separator" + const val DICEWARE_LENGTH = "diceware_length" + const val DISABLE_SYNC_ACTION = "disable_sync_action" +} diff --git a/app/src/main/java/app/passwordstore/util/shortcuts/ShortcutHandler.kt b/app/src/main/java/app/passwordstore/util/shortcuts/ShortcutHandler.kt new file mode 100644 index 00000000..03d14b9c --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/shortcuts/ShortcutHandler.kt @@ -0,0 +1,106 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.shortcuts + +import android.content.Context +import android.content.Intent +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager +import android.graphics.drawable.Icon +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.content.getSystemService +import app.passwordstore.R +import app.passwordstore.data.password.PasswordItem +import dagger.Reusable +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import logcat.logcat + +@Reusable +class ShortcutHandler +@Inject +constructor( + @ApplicationContext val context: Context, +) { + + private companion object { + + // The max shortcut count from the system is set to 15 for some godforsaken reason, which + // makes zero sense and is why our update logic just never worked. Capping it at 4 which is + // what most launchers seem to have agreed upon is the only reasonable solution. + private const val MAX_SHORTCUT_COUNT = 4 + } + + /** + * Creates a + * [dynamic shortcut](https://developer.android.com/guide/topics/ui/shortcuts/creating-shortcuts#dynamic) + * that shows up with the app icon on long press. The list of items is capped to + * [MAX_SHORTCUT_COUNT] and older items are removed by a simple LRU sweep. + */ + fun addDynamicShortcut(item: PasswordItem, intent: Intent) { + if (Build.VERSION.SDK_INT < 25) return + val shortcutManager: ShortcutManager = context.getSystemService() ?: return + val shortcut = buildShortcut(item, intent) + val shortcuts = shortcutManager.dynamicShortcuts + // If we're above or equal to the maximum shortcuts allowed, drop the last item. + if (shortcuts.size >= MAX_SHORTCUT_COUNT) { + shortcuts.removeLast() + } + // Reverse the list so we can append our new shortcut at the 'end'. + shortcuts.reverse() + shortcuts.add(shortcut) + // Reverse it again, so the previous items are now in the correct order and our new item + // is at the front like it's supposed to. + shortcuts.reverse() + // Write back the new shortcuts. + shortcutManager.dynamicShortcuts = shortcuts.map(::rebuildShortcut) + } + + /** + * Creates a + * [pinned shortcut](https://developer.android.com/guide/topics/ui/shortcuts/creating-shortcuts#pinned) + * which presents a UI to users, allowing manual placement on the launcher screen. This method is + * a no-op if the user's default launcher does not support pinned shortcuts. + */ + fun addPinnedShortcut(item: PasswordItem, intent: Intent) { + if (Build.VERSION.SDK_INT < 26) return + val shortcutManager: ShortcutManager = context.getSystemService() ?: return + if (!shortcutManager.isRequestPinShortcutSupported) { + logcat { "addPinnedShortcut: pin shortcuts unsupported" } + return + } + val shortcut = buildShortcut(item, intent) + shortcutManager.requestPinShortcut(shortcut, null) + } + + /** Creates a [ShortcutInfo] from [item] and assigns [intent] to it. */ + @RequiresApi(25) + private fun buildShortcut(item: PasswordItem, intent: Intent): ShortcutInfo { + return ShortcutInfo.Builder(context, item.fullPathToParent) + .setShortLabel(item.toString()) + .setLongLabel(item.fullPathToParent + item.toString()) + .setIcon(Icon.createWithResource(context, R.drawable.ic_lock_open_24px)) + .setIntent(intent) + .build() + } + + /** + * Takes an existing [ShortcutInfo] and builds a fresh instance of [ShortcutInfo] with the same + * data, which ensures that the get/set dance in [addDynamicShortcut] does not cause invalidation + * of icon assets, resulting in invisible icons in all but the newest launcher shortcut. + */ + @RequiresApi(25) + private fun rebuildShortcut(shortcut: ShortcutInfo): ShortcutInfo { + // Non-null assertions are fine since we know these values aren't null. + return ShortcutInfo.Builder(context, shortcut.id) + .setShortLabel(shortcut.shortLabel!!) + .setLongLabel(shortcut.longLabel!!) + .setIcon(Icon.createWithResource(context, R.drawable.ic_lock_open_24px)) + .setIntent(shortcut.intent!!) + .build() + } +} diff --git a/app/src/main/java/app/passwordstore/util/totp/UriTotpFinder.kt b/app/src/main/java/app/passwordstore/util/totp/UriTotpFinder.kt new file mode 100644 index 00000000..30447690 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/totp/UriTotpFinder.kt @@ -0,0 +1,53 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.totp + +import android.net.Uri +import javax.inject.Inject + +/** [Uri] backed TOTP URL parser. */ +class UriTotpFinder @Inject constructor() : TotpFinder { + + override fun findSecret(content: String): String? { + content.split("\n".toRegex()).forEach { line -> + if (line.startsWith(TotpFinder.TOTP_FIELDS[0])) { + return Uri.parse(line).getQueryParameter("secret") + } + if (line.startsWith(TotpFinder.TOTP_FIELDS[1], ignoreCase = true)) { + return line.split(": *".toRegex(), 2).toTypedArray()[1] + } + } + return null + } + + override fun findDigits(content: String): String { + return getQueryParameter(content, "digits") ?: "6" + } + + override fun findPeriod(content: String): Long { + return getQueryParameter(content, "period")?.toLongOrNull() ?: 30 + } + + override fun findAlgorithm(content: String): String { + return getQueryParameter(content, "algorithm") ?: "sha1" + } + + override fun findIssuer(content: String): String? { + return getQueryParameter(content, "issuer") ?: Uri.parse(content).authority + } + + private fun getQueryParameter(content: String, parameterName: String): String? { + content.split("\n".toRegex()).forEach { line -> + val uri = Uri.parse(line) + if ( + line.startsWith(TotpFinder.TOTP_FIELDS[0]) && uri.getQueryParameter(parameterName) != null + ) { + return uri.getQueryParameter(parameterName) + } + } + return null + } +} diff --git a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt new file mode 100644 index 00000000..8cb18309 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt @@ -0,0 +1,473 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.viewmodel + +import android.app.Application +import android.os.Parcelable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.asFlow +import androidx.lifecycle.asLiveData +import androidx.recyclerview.selection.ItemDetailsLookup +import androidx.recyclerview.selection.ItemKeyProvider +import androidx.recyclerview.selection.Selection +import androidx.recyclerview.selection.SelectionPredicates +import androidx.recyclerview.selection.SelectionTracker +import androidx.recyclerview.selection.StorageStrategy +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import app.passwordstore.data.password.PasswordItem +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.util.autofill.AutofillPreferences +import app.passwordstore.util.autofill.DirectoryStructure +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.extensions.unsafeLazy +import app.passwordstore.util.settings.PasswordSortOrder +import app.passwordstore.util.settings.PreferenceKeys +import com.github.androidpasswordstore.sublimefuzzy.Fuzzy +import java.io.File +import java.text.Collator +import java.util.Locale +import java.util.Stack +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield +import me.zhanghai.android.fastscroll.PopupTextProvider + +private fun File.toPasswordItem() = + if (isFile) PasswordItem.newPassword(name, this, PasswordRepository.getRepositoryDirectory()) + else PasswordItem.newCategory(name, this, PasswordRepository.getRepositoryDirectory()) + +private fun PasswordItem.fuzzyMatch(filter: String): Int { + val (_, score) = Fuzzy.fuzzyMatch(filter, longName) + return score +} + +private val CaseInsensitiveComparator = Collator.getInstance().apply { strength = Collator.PRIMARY } + +private fun PasswordItem.Companion.makeComparator( + typeSortOrder: PasswordSortOrder, + directoryStructure: DirectoryStructure +): Comparator { + return when (typeSortOrder) { + PasswordSortOrder.FOLDER_FIRST -> compareBy { it.type } + // In order to let INDEPENDENT not distinguish between items based on their type, we + // simply + // declare them all equal at this stage. + PasswordSortOrder.INDEPENDENT -> Comparator { _, _ -> 0 } + PasswordSortOrder.FILE_FIRST -> compareByDescending { it.type } + PasswordSortOrder.RECENTLY_USED -> PasswordSortOrder.RECENTLY_USED.comparator + } + .then( + compareBy(nullsLast(CaseInsensitiveComparator)) { + directoryStructure.getIdentifierFor(it.file) + } + ) + .then( + compareBy(nullsLast(CaseInsensitiveComparator)) { directoryStructure.getUsernameFor(it.file) } + ) +} + +val PasswordItem.stableId: String + get() = file.absolutePath + +enum class FilterMode { + NoFilter, + StrictDomain, + Fuzzy +} + +enum class SearchMode { + RecursivelyInSubdirectories, + InCurrentDirectoryOnly +} + +enum class ListMode { + FilesOnly, + DirectoriesOnly, + AllEntries +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +class SearchableRepositoryViewModel(application: Application) : AndroidViewModel(application) { + + private var _updateCounter = 0 + private val updateCounter: Int + get() = _updateCounter + + private fun forceUpdateOnNextSearchAction() { + _updateCounter++ + } + + private val root + get() = PasswordRepository.getRepositoryDirectory() + private val settings by unsafeLazy { application.sharedPrefs } + private val showHiddenContents + get() = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false) + private val defaultSearchMode + get() = + if (settings.getBoolean(PreferenceKeys.FILTER_RECURSIVELY, true)) { + SearchMode.RecursivelyInSubdirectories + } else { + SearchMode.InCurrentDirectoryOnly + } + + private val typeSortOrder + get() = PasswordSortOrder.getSortOrder(settings) + private val directoryStructure + get() = AutofillPreferences.directoryStructure(getApplication()) + private val itemComparator + get() = PasswordItem.makeComparator(typeSortOrder, directoryStructure) + + private data class SearchAction( + val baseDirectory: File, + val filter: String, + val filterMode: FilterMode, + val searchMode: SearchMode, + val listMode: ListMode, + // This counter can be increased to force a reexecution of the search action even if all + // other arguments are left unchanged. + val updateCounter: Int + ) + + private fun makeSearchAction( + baseDirectory: File, + filter: String, + filterMode: FilterMode, + searchMode: SearchMode, + listMode: ListMode + ): SearchAction { + return SearchAction( + baseDirectory = baseDirectory, + filter = filter, + filterMode = filterMode, + searchMode = searchMode, + listMode = listMode, + updateCounter = updateCounter + ) + } + + private fun updateSearchAction(action: SearchAction) = action.copy(updateCounter = updateCounter) + + private val searchAction = + MutableLiveData( + makeSearchAction( + baseDirectory = root, + filter = "", + filterMode = FilterMode.NoFilter, + searchMode = SearchMode.InCurrentDirectoryOnly, + listMode = ListMode.AllEntries + ) + ) + private val searchActionFlow = searchAction.asFlow().distinctUntilChanged() + + data class SearchResult(val passwordItems: List, val isFiltered: Boolean) + + val searchResult = + searchActionFlow + .mapLatest { searchAction -> + val listResultFlow = + when (searchAction.searchMode) { + SearchMode.RecursivelyInSubdirectories -> + listFilesRecursively(searchAction.baseDirectory) + SearchMode.InCurrentDirectoryOnly -> listFiles(searchAction.baseDirectory) + } + val prefilteredResultFlow = + when (searchAction.listMode) { + ListMode.FilesOnly -> listResultFlow.filter { it.isFile } + ListMode.DirectoriesOnly -> listResultFlow.filter { it.isDirectory } + ListMode.AllEntries -> listResultFlow + } + val passwordList = + when (if (searchAction.filter == "") FilterMode.NoFilter else searchAction.filterMode) { + FilterMode.NoFilter -> { + prefilteredResultFlow.map { it.toPasswordItem() }.toList().sortedWith(itemComparator) + } + FilterMode.StrictDomain -> { + check(searchAction.listMode == ListMode.FilesOnly) { + "Searches with StrictDomain search mode can only list files" + } + val regex = generateStrictDomainRegex(searchAction.filter) + if (regex != null) { + prefilteredResultFlow + .filter { absoluteFile -> + regex.containsMatchIn(absoluteFile.relativeTo(root).path) + } + .map { it.toPasswordItem() } + .toList() + .sortedWith(itemComparator) + } else { + emptyList() + } + } + FilterMode.Fuzzy -> { + prefilteredResultFlow + .map { + val item = it.toPasswordItem() + Pair(item.fuzzyMatch(searchAction.filter), item) + } + .filter { it.first > 0 } + .toList() + .sortedWith( + compareByDescending> { it.first } + .thenBy(itemComparator) { it.second } + ) + .map { it.second } + } + } + SearchResult(passwordList, isFiltered = searchAction.filterMode != FilterMode.NoFilter) + } + .asLiveData(Dispatchers.IO) + + private fun shouldTake(file: File) = + with(file) { + if (showHiddenContents) { + return !file.name.startsWith(".git") + } + if (isDirectory) { + !isHidden + } else { + !isHidden && file.extension == "gpg" + } + } + + private fun listFiles(dir: File): Flow { + return dir.listFiles(::shouldTake)?.asFlow() ?: emptyFlow() + } + + private fun listFilesRecursively(dir: File): Flow { + return dir + // Take top directory even if it is hidden. + .walkTopDown() + .onEnter { file -> file == dir || shouldTake(file) } + .asFlow() + // Skip the root directory + .drop(1) + .map { + yield() + it + } + .filter(::shouldTake) + } + + private val _currentDir = MutableLiveData(root) + val currentDir = _currentDir as LiveData + + data class NavigationStackEntry(val dir: File, val recyclerViewState: Parcelable?) + + private val navigationStack = Stack() + + fun navigateTo( + newDirectory: File = root, + listMode: ListMode = ListMode.AllEntries, + recyclerViewState: Parcelable? = null, + pushPreviousLocation: Boolean = true + ) { + if (!newDirectory.exists()) return + require(newDirectory.isDirectory) { "Can only navigate to a directory" } + if (pushPreviousLocation) { + navigationStack.push(NavigationStackEntry(_currentDir.value!!, recyclerViewState)) + } + searchAction.postValue( + makeSearchAction( + filter = "", + baseDirectory = newDirectory, + filterMode = FilterMode.NoFilter, + searchMode = SearchMode.InCurrentDirectoryOnly, + listMode = listMode + ) + ) + _currentDir.postValue(newDirectory) + } + + val canNavigateBack + get() = navigationStack.isNotEmpty() + + /** + * Navigate back to the last location on the [navigationStack] and restore a cached scroll + * position if possible. + * + * Returns the old RecyclerView's LinearLayoutManager state as a [Parcelable] if it was cached. + */ + fun navigateBack(): Parcelable? { + if (!canNavigateBack) return null + val (oldDir, oldRecyclerViewState) = navigationStack.pop() + navigateTo(oldDir, pushPreviousLocation = false) + return oldRecyclerViewState + } + + fun reset() { + navigationStack.clear() + forceUpdateOnNextSearchAction() + navigateTo(pushPreviousLocation = false) + } + + fun search( + filter: String, + baseDirectory: File? = null, + filterMode: FilterMode = FilterMode.Fuzzy, + searchMode: SearchMode? = null, + listMode: ListMode = ListMode.AllEntries + ) { + require(baseDirectory?.isDirectory != false) { "Can only search in a directory" } + searchAction.postValue( + makeSearchAction( + filter = filter, + baseDirectory = baseDirectory ?: _currentDir.value!!, + filterMode = filterMode, + searchMode = searchMode ?: defaultSearchMode, + listMode = listMode + ) + ) + } + + fun forceRefresh() { + forceUpdateOnNextSearchAction() + searchAction.postValue(updateSearchAction(searchAction.value!!)) + } + + companion object { + + @VisibleForTesting + fun generateStrictDomainRegex(domain: String): Regex? { + // Valid domains do not contain path separators. + if (domain.contains('/')) return null + // Matches the start of a path component, which is either the start of the + // string or a path separator. + val prefix = """(?:^|/)""" + val escapedFilter = Regex.escape(domain.replace("/", "")) + // Matches either the filter literally or a strict subdomain of the filter term. + // We allow a lot of freedom in what a subdomain is, as long as it is not an + // email address. + val subdomain = """(?:(?:[^/@]+\.)?$escapedFilter)""" + // Matches the end of a path component, which is either the literal ".gpg" or a + // path separator. + val suffix = """(?:\.gpg|/)""" + // Match any relative path with a component that is a subdomain of the filter. + return Regex(prefix + subdomain + suffix) + } + } +} + +private object PasswordItemDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = + oldItem.file.absolutePath == newItem.file.absolutePath + + override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = oldItem == newItem +} + +open class SearchableRepositoryAdapter( + private val layoutRes: Int, + private val viewHolderCreator: (view: View) -> T, + private val coroutineScope: CoroutineScope, + private val viewHolderBinder: suspend T.(item: PasswordItem) -> Unit, +) : ListAdapter(PasswordItemDiffCallback), PopupTextProvider { + + fun > makeSelectable( + recyclerView: RecyclerView, + itemDetailsLookupCreator: (recyclerView: RecyclerView) -> T + ) { + selectionTracker = + SelectionTracker.Builder( + "SearchableRepositoryAdapter", + recyclerView, + itemKeyProvider, + itemDetailsLookupCreator(recyclerView), + StorageStrategy.createStringStorage() + ) + .withSelectionPredicate(SelectionPredicates.createSelectAnything()) + .build() + .apply { + addObserver( + object : SelectionTracker.SelectionObserver() { + override fun onSelectionChanged() { + this@SearchableRepositoryAdapter.onSelectionChangedListener?.invoke( + requireSelectionTracker().selection + ) + } + } + ) + } + } + + private var onItemClickedListener: ((holder: T, item: PasswordItem) -> Unit)? = null + open fun onItemClicked( + listener: (holder: T, item: PasswordItem) -> Unit + ): SearchableRepositoryAdapter { + check(onItemClickedListener == null) { + "Only a single listener can be registered for onItemClicked" + } + onItemClickedListener = listener + return this + } + + private var onSelectionChangedListener: ((selection: Selection) -> Unit)? = null + open fun onSelectionChanged( + listener: (selection: Selection) -> Unit + ): SearchableRepositoryAdapter { + check(onSelectionChangedListener == null) { + "Only a single listener can be registered for onSelectionChanged" + } + onSelectionChangedListener = listener + return this + } + + private val itemKeyProvider = + object : ItemKeyProvider(SCOPE_MAPPED) { + override fun getKey(position: Int) = getItem(position).stableId + + override fun getPosition(key: String) = + (0 until itemCount).firstOrNull { getItem(it).stableId == key } ?: RecyclerView.NO_POSITION + } + + private var selectionTracker: SelectionTracker? = null + fun requireSelectionTracker() = selectionTracker!! + + private val selectedFiles + get() = requireSelectionTracker().selection.map { File(it) } + + fun getSelectedItems() = selectedFiles.map { it.toPasswordItem() } + + fun getPositionForFile(file: File) = itemKeyProvider.getPosition(file.absolutePath) + + final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T { + val view = LayoutInflater.from(parent.context).inflate(layoutRes, parent, false) + return viewHolderCreator(view) + } + + final override fun onBindViewHolder(holder: T, position: Int) { + val item = getItem(position) + holder.apply { + coroutineScope.launch(Dispatchers.Main.immediate) { viewHolderBinder.invoke(holder, item) } + selectionTracker?.let { itemView.isSelected = it.isSelected(item.stableId) } + itemView.setOnClickListener { + // Do not emit custom click events while the user is selecting items. + if (selectionTracker?.hasSelection() != true) onItemClickedListener?.invoke(holder, item) + } + } + } + final override fun getPopupText(position: Int): String { + return getItem(position).name[0].toString().uppercase(Locale.getDefault()) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/Application.kt b/app/src/main/java/dev/msfjarvis/aps/Application.kt deleted file mode 100644 index 6aa6e53b..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/Application.kt +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps - -import android.content.SharedPreferences -import androidx.appcompat.app.AppCompatDelegate -import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY -import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO -import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES -import com.google.android.material.color.DynamicColors -import dagger.hilt.android.HiltAndroidApp -import dev.msfjarvis.aps.injection.context.FilesDirPath -import dev.msfjarvis.aps.injection.prefs.SettingsPreferences -import dev.msfjarvis.aps.util.extensions.getString -import dev.msfjarvis.aps.util.features.Feature -import dev.msfjarvis.aps.util.features.Features -import dev.msfjarvis.aps.util.git.sshj.setUpBouncyCastleForSshj -import dev.msfjarvis.aps.util.proxy.ProxyUtils -import dev.msfjarvis.aps.util.settings.GitSettings -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import dev.msfjarvis.aps.util.settings.runMigrations -import io.sentry.Sentry -import io.sentry.protocol.User -import javax.inject.Inject -import logcat.AndroidLogcatLogger -import logcat.LogPriority.DEBUG -import logcat.LogcatLogger - -@Suppress("Unused") -@HiltAndroidApp -class Application : android.app.Application(), SharedPreferences.OnSharedPreferenceChangeListener { - - @Inject @SettingsPreferences lateinit var prefs: SharedPreferences - @Inject @FilesDirPath lateinit var filesDirPath: String - @Inject lateinit var proxyUtils: ProxyUtils - @Inject lateinit var gitSettings: GitSettings - @Inject lateinit var features: Features - - override fun onCreate() { - super.onCreate() - instance = this - if ( - BuildConfig.ENABLE_DEBUG_FEATURES || - prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false) - ) { - LogcatLogger.install(AndroidLogcatLogger(DEBUG)) - } - prefs.registerOnSharedPreferenceChangeListener(this) - setNightMode() - setUpBouncyCastleForSshj() - runMigrations(filesDirPath, prefs, gitSettings) - proxyUtils.setDefaultProxy() - DynamicColors.applyToActivitiesIfAvailable(this) - Sentry.configureScope { scope -> - val user = User() - user.others = - Feature.VALUES.associate { feature -> - "features.${feature.configKey}" to features.isEnabled(feature).toString() - } - scope.user = user - } - } - - override fun onTerminate() { - prefs.unregisterOnSharedPreferenceChangeListener(this) - super.onTerminate() - } - - override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String) { - if (key == PreferenceKeys.APP_THEME) { - setNightMode() - } - } - - private fun setNightMode() { - AppCompatDelegate.setDefaultNightMode( - when (prefs.getString(PreferenceKeys.APP_THEME) ?: getString(R.string.app_theme_def)) { - "light" -> MODE_NIGHT_NO - "dark" -> MODE_NIGHT_YES - "follow_system" -> MODE_NIGHT_FOLLOW_SYSTEM - else -> MODE_NIGHT_AUTO_BATTERY - } - ) - } - - companion object { - - lateinit var instance: Application - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/data/crypto/CryptoRepository.kt b/app/src/main/java/dev/msfjarvis/aps/data/crypto/CryptoRepository.kt deleted file mode 100644 index 0e262e7a..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/data/crypto/CryptoRepository.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.data.crypto - -import com.github.michaelbull.result.unwrap -import dev.msfjarvis.aps.crypto.PGPKeyManager -import dev.msfjarvis.aps.crypto.PGPainlessCryptoHandler -import dev.msfjarvis.aps.util.extensions.isOk -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -class CryptoRepository -@Inject -constructor( - private val pgpKeyManager: PGPKeyManager, - private val pgpCryptoHandler: PGPainlessCryptoHandler, -) { - - suspend fun decrypt( - password: String, - message: ByteArrayInputStream, - out: ByteArrayOutputStream, - ) { - withContext(Dispatchers.IO) { decryptPgp(password, message, out) } - } - - suspend fun encrypt(content: ByteArrayInputStream, out: ByteArrayOutputStream) { - withContext(Dispatchers.IO) { encryptPgp(content, out) } - } - - private suspend fun decryptPgp( - password: String, - message: ByteArrayInputStream, - out: ByteArrayOutputStream, - ) { - val keys = pgpKeyManager.getAllKeys().unwrap() - // Iterates through the keys until the first successful decryption, then returns. - keys.firstOrNull { key -> pgpCryptoHandler.decrypt(key, password, message, out).isOk() } - } - - private suspend fun encryptPgp(content: ByteArrayInputStream, out: ByteArrayOutputStream) { - val keys = pgpKeyManager.getAllKeys().unwrap() - pgpCryptoHandler.encrypt( - keys, - content, - out, - ) - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/data/password/FieldItem.kt b/app/src/main/java/dev/msfjarvis/aps/data/password/FieldItem.kt deleted file mode 100644 index e7c88ef1..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/data/password/FieldItem.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.data.password - -import dev.msfjarvis.aps.data.passfile.Totp -import kotlin.time.ExperimentalTime - -@OptIn(ExperimentalTime::class) -class FieldItem(val key: String, val value: String, val action: ActionType) { - enum class ActionType { - COPY, - HIDE - } - - enum class ItemType(val type: String, val label: String) { - USERNAME("Username", "Username"), - PASSWORD("Password", "Password"), - OTP("OTP", "OTP (expires in %ds)"), - } - - companion object { - - // Extra helper methods - fun createOtpField(totp: Totp): FieldItem { - return FieldItem( - ItemType.OTP.label.format(totp.remainingTime.inWholeSeconds), - totp.value, - ActionType.COPY, - ) - } - - fun createPasswordField(password: String): FieldItem { - return FieldItem(ItemType.PASSWORD.label, password, ActionType.HIDE) - } - - fun createUsernameField(username: String): FieldItem { - return FieldItem(ItemType.USERNAME.label, username, ActionType.COPY) - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordItem.kt b/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordItem.kt deleted file mode 100644 index 82b0dc35..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordItem.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.data.password - -import android.content.Context -import android.content.Intent -import dev.msfjarvis.aps.data.repo.PasswordRepository -import dev.msfjarvis.aps.ui.crypto.BasePgpActivity -import dev.msfjarvis.aps.ui.main.LaunchActivity -import java.io.File - -data class PasswordItem( - val name: String, - val parent: PasswordItem? = null, - val type: Char, - val file: File, - val rootDir: File -) : Comparable { - - val fullPathToParent = file.absolutePath.replace(rootDir.absolutePath, "").replace(file.name, "") - - val longName = BasePgpActivity.getLongName(fullPathToParent, rootDir.absolutePath, toString()) - - override fun equals(other: Any?): Boolean { - return (other is PasswordItem) && (other.file == file) - } - - override fun compareTo(other: PasswordItem): Int { - return (type + name).compareTo(other.type + other.name, ignoreCase = true) - } - - override fun toString(): String { - return name.replace("\\.gpg$".toRegex(), "") - } - - override fun hashCode(): Int { - return 0 - } - - /** Creates an [Intent] to launch this [PasswordItem] through the authentication process. */ - fun createAuthEnabledIntent(context: Context): Intent { - val intent = Intent(context, LaunchActivity::class.java) - intent.putExtra("NAME", toString()) - intent.putExtra("FILE_PATH", file.absolutePath) - intent.putExtra("REPO_PATH", PasswordRepository.getRepositoryDirectory().absolutePath) - intent.action = LaunchActivity.ACTION_DECRYPT_PASS - return intent - } - - companion object { - - const val TYPE_CATEGORY = 'c' - const val TYPE_PASSWORD = 'p' - - @JvmStatic - fun newCategory(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem { - return PasswordItem(name, parent, TYPE_CATEGORY, file, rootDir) - } - - @JvmStatic - fun newCategory(name: String, file: File, rootDir: File): PasswordItem { - return PasswordItem(name, null, TYPE_CATEGORY, file, rootDir) - } - - @JvmStatic - fun newPassword(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem { - return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir) - } - - @JvmStatic - fun newPassword(name: String, file: File, rootDir: File): PasswordItem { - return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir) - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/data/repo/PasswordRepository.kt b/app/src/main/java/dev/msfjarvis/aps/data/repo/PasswordRepository.kt deleted file mode 100644 index f675e80a..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/data/repo/PasswordRepository.kt +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.data.repo - -import androidx.core.content.edit -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import dev.msfjarvis.aps.Application -import dev.msfjarvis.aps.data.password.PasswordItem -import dev.msfjarvis.aps.util.extensions.sharedPrefs -import dev.msfjarvis.aps.util.extensions.unsafeLazy -import dev.msfjarvis.aps.util.settings.PasswordSortOrder -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import java.io.File -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.lib.Repository -import org.eclipse.jgit.storage.file.FileRepositoryBuilder -import org.eclipse.jgit.transport.RefSpec -import org.eclipse.jgit.transport.RemoteConfig -import org.eclipse.jgit.transport.URIish - -object PasswordRepository { - - var repository: Repository? = null - private val settings by unsafeLazy { Application.instance.sharedPrefs } - private val filesDir - get() = Application.instance.filesDir - val isInitialized: Boolean - get() = repository != null - - fun isGitRepo(): Boolean { - return repository?.objectDatabase?.exists() ?: false - } - - /** - * Takes in a [repositoryDir] to initialize a Git repository with, and assigns it to [repository] - * as static state. - */ - private fun initializeRepository(repositoryDir: File) { - val builder = FileRepositoryBuilder() - repository = - runCatching { builder.setGitDir(repositoryDir).build() } - .getOrElse { e -> - e.printStackTrace() - null - } - } - - fun createRepository(repositoryDir: File) { - repositoryDir.delete() - Git.init().setDirectory(repositoryDir).call() - initializeRepository(repositoryDir) - } - - // TODO add multiple remotes support for pull/push - fun addRemote(name: String, url: String, replace: Boolean = false) { - val storedConfig = repository!!.config - val remotes = storedConfig.getSubsections("remote") - - if (!remotes.contains(name)) { - runCatching { - val uri = URIish(url) - val refSpec = RefSpec("+refs/head/*:refs/remotes/$name/*") - - val remoteConfig = RemoteConfig(storedConfig, name) - remoteConfig.addFetchRefSpec(refSpec) - remoteConfig.addPushRefSpec(refSpec) - remoteConfig.addURI(uri) - remoteConfig.addPushURI(uri) - - remoteConfig.update(storedConfig) - - storedConfig.save() - } - .onFailure { e -> e.printStackTrace() } - } else if (replace) { - runCatching { - val uri = URIish(url) - - val remoteConfig = RemoteConfig(storedConfig, name) - // remove the first and eventually the only uri - if (remoteConfig.urIs.size > 0) { - remoteConfig.removeURI(remoteConfig.urIs[0]) - } - if (remoteConfig.pushURIs.size > 0) { - remoteConfig.removePushURI(remoteConfig.pushURIs[0]) - } - - remoteConfig.addURI(uri) - remoteConfig.addPushURI(uri) - - remoteConfig.update(storedConfig) - - storedConfig.save() - } - .onFailure { e -> e.printStackTrace() } - } - } - - fun closeRepository() { - repository?.close() - repository = null - } - - fun getRepositoryDirectory(): File { - return File(filesDir.toString(), "/store") - } - - fun initialize(): Repository? { - val dir = getRepositoryDirectory() - // Un-initialize the repo if the dir does not exist or is absolutely empty - settings.edit { - if (!dir.exists() || !dir.isDirectory || requireNotNull(dir.listFiles()).isEmpty()) { - putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) - } else { - putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) - } - } - // Create the repository static variable in PasswordRepository - initializeRepository(dir.resolve(".git")) - - return repository - } - - /** - * Gets the .gpg files in a directory - * - * @param path the directory path - * @return the list of gpg files in that directory - */ - private fun getFilesList(path: File): ArrayList { - if (!path.exists()) return ArrayList() - val files = - (path.listFiles { file -> file.isDirectory || file.extension == "gpg" } ?: emptyArray()) - .toList() - val items = ArrayList() - items.addAll(files) - return items - } - - /** - * Gets the passwords (PasswordItem) in a directory - * - * @param path the directory path - * @return a list of password items - */ - fun getPasswords( - path: File, - rootDir: File, - sortOrder: PasswordSortOrder - ): ArrayList { - // We need to recover the passwords then parse the files - val passList = getFilesList(path).also { it.sortBy { f -> f.name } } - val passwordList = ArrayList() - val showHidden = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false) - - if (passList.size == 0) return passwordList - if (!showHidden) { - passList.filter { !it.isHidden }.toCollection(passList.apply { clear() }) - } - passList.forEach { file -> - passwordList.add( - if (file.isFile) { - PasswordItem.newPassword(file.name, file, rootDir) - } else { - PasswordItem.newCategory(file.name, file, rootDir) - } - ) - } - passwordList.sortWith(sortOrder.comparator) - return passwordList - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/context/ContextModule.kt b/app/src/main/java/dev/msfjarvis/aps/injection/context/ContextModule.kt deleted file mode 100644 index 41e59bf9..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/injection/context/ContextModule.kt +++ /dev/null @@ -1,27 +0,0 @@ -package dev.msfjarvis.aps.injection.context - -import android.content.Context -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent - -@Module -@InstallIn(SingletonComponent::class) -class ContextModule { - - /** - * We inject [Context.getFilesDir] to break the dependency on [Context], allowing tests to run on - * the JVM. The principle here is identical to why [dev.msfjarvis.aps.util.totp.TotpFinder] - * exists. - * - * @param context [ApplicationContext] - * @return the path of app-specific files directory. - */ - @Provides - @FilesDirPath - fun providesFilesDirPath(@ApplicationContext context: Context): String { - return context.filesDir.path - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/context/FilesDirPath.kt b/app/src/main/java/dev/msfjarvis/aps/injection/context/FilesDirPath.kt deleted file mode 100644 index f6419354..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/injection/context/FilesDirPath.kt +++ /dev/null @@ -1,7 +0,0 @@ -package dev.msfjarvis.aps.injection.context - -import android.content.Context -import javax.inject.Qualifier - -/** Qualifies a [String] representing the absolute path of [Context.getFilesDir]. */ -@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class FilesDirPath diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/coroutines/DispatcherModule.kt b/app/src/main/java/dev/msfjarvis/aps/injection/coroutines/DispatcherModule.kt deleted file mode 100644 index bf84fc27..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/injection/coroutines/DispatcherModule.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.injection.coroutines - -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import dev.msfjarvis.aps.util.coroutines.DefaultDispatcherProvider -import dev.msfjarvis.aps.util.coroutines.DispatcherProvider - -@Module -@InstallIn(SingletonComponent::class) -interface DispatcherModule { - @Binds fun DefaultDispatcherProvider.bind(): DispatcherProvider -} diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/crypto/CryptoHandlerModule.kt b/app/src/main/java/dev/msfjarvis/aps/injection/crypto/CryptoHandlerModule.kt deleted file mode 100644 index ef6a11ce..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/injection/crypto/CryptoHandlerModule.kt +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.injection.crypto - -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import dev.msfjarvis.aps.crypto.PGPainlessCryptoHandler - -@Module -@InstallIn(SingletonComponent::class) -object CryptoHandlerModule { - @Provides fun providePgpCryptoHandler() = PGPainlessCryptoHandler() -} diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/crypto/KeyManagerModule.kt b/app/src/main/java/dev/msfjarvis/aps/injection/crypto/KeyManagerModule.kt deleted file mode 100644 index a1119a1c..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/injection/crypto/KeyManagerModule.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.injection.crypto - -import android.content.Context -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import dev.msfjarvis.aps.crypto.PGPKeyManager -import dev.msfjarvis.aps.util.coroutines.DispatcherProvider -import javax.inject.Qualifier - -@Module -@InstallIn(SingletonComponent::class) -object KeyManagerModule { - @Provides - fun providePGPKeyManager( - @PGPKeyDir keyDir: String, - dispatcherProvider: DispatcherProvider, - ): PGPKeyManager { - return PGPKeyManager( - keyDir, - dispatcherProvider.io(), - ) - } - - @Provides - @PGPKeyDir - fun providePGPKeyDir(@ApplicationContext context: Context): String { - return context.filesDir.resolve("pgp_keys").absolutePath - } -} - -@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class PGPKeyDir diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/prefs/GitPreferences.kt b/app/src/main/java/dev/msfjarvis/aps/injection/prefs/GitPreferences.kt deleted file mode 100644 index 2947a64d..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/injection/prefs/GitPreferences.kt +++ /dev/null @@ -1,9 +0,0 @@ -package dev.msfjarvis.aps.injection.prefs - -import android.content.SharedPreferences -import javax.inject.Qualifier - -/** - * Qualifies a [SharedPreferences] instance specifically used for encrypted Git-related settings. - */ -@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class GitPreferences diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/prefs/PasswordGeneratorPreferences.kt b/app/src/main/java/dev/msfjarvis/aps/injection/prefs/PasswordGeneratorPreferences.kt deleted file mode 100644 index 14b3a6f2..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/injection/prefs/PasswordGeneratorPreferences.kt +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.injection.prefs - -import javax.inject.Qualifier - -@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class PasswordGeneratorPreferences diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/prefs/PreferenceModule.kt b/app/src/main/java/dev/msfjarvis/aps/injection/prefs/PreferenceModule.kt deleted file mode 100644 index e68a998f..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/injection/prefs/PreferenceModule.kt +++ /dev/null @@ -1,60 +0,0 @@ -package dev.msfjarvis.aps.injection.prefs - -import android.content.Context -import android.content.Context.MODE_PRIVATE -import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import dagger.Module -import dagger.Provides -import dagger.Reusable -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import dev.msfjarvis.aps.BuildConfig - -@Module -@InstallIn(SingletonComponent::class) -class PreferenceModule { - - private fun provideBaseEncryptedPreferences( - context: Context, - fileName: String - ): SharedPreferences { - val masterKeyAlias = - MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() - return EncryptedSharedPreferences.create( - context, - fileName, - masterKeyAlias, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) - } - - @[Provides PasswordGeneratorPreferences Reusable] - fun providePwgenPreferences(@ApplicationContext context: Context): SharedPreferences { - return provideBaseEncryptedPreferences(context, "pwgen_preferences") - } - - @Provides - @SettingsPreferences - @Reusable - fun provideSettingsPreferences(@ApplicationContext context: Context): SharedPreferences { - return context.getSharedPreferences("${BuildConfig.APPLICATION_ID}_preferences", MODE_PRIVATE) - } - - @Provides - @GitPreferences - @Reusable - fun provideEncryptedPreferences(@ApplicationContext context: Context): SharedPreferences { - return provideBaseEncryptedPreferences(context, "git_operation") - } - - @Provides - @ProxyPreferences - @Reusable - fun provideProxyPreferences(@ApplicationContext context: Context): SharedPreferences { - return provideBaseEncryptedPreferences(context, "http_proxy") - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/prefs/ProxyPreferences.kt b/app/src/main/java/dev/msfjarvis/aps/injection/prefs/ProxyPreferences.kt deleted file mode 100644 index 5fa99140..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/injection/prefs/ProxyPreferences.kt +++ /dev/null @@ -1,9 +0,0 @@ -package dev.msfjarvis.aps.injection.prefs - -import android.content.SharedPreferences -import javax.inject.Qualifier - -/** - * Qualifies a [SharedPreferences] instance specifically used for encrypted proxy-related settings. - */ -@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class ProxyPreferences diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/prefs/SettingsPreferences.kt b/app/src/main/java/dev/msfjarvis/aps/injection/prefs/SettingsPreferences.kt deleted file mode 100644 index 7bca03c8..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/injection/prefs/SettingsPreferences.kt +++ /dev/null @@ -1,5 +0,0 @@ -package dev.msfjarvis.aps.injection.prefs - -import javax.inject.Qualifier - -@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class SettingsPreferences diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/pwgen/DicewareModule.kt b/app/src/main/java/dev/msfjarvis/aps/injection/pwgen/DicewareModule.kt deleted file mode 100644 index 8aed12de..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/injection/pwgen/DicewareModule.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.injection.pwgen - -import android.content.Context -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.components.FragmentComponent -import dagger.hilt.android.qualifiers.ApplicationContext -import dev.msfjarvis.aps.passgen.diceware.DicewarePassphraseGenerator -import dev.msfjarvis.aps.passgen.diceware.Die -import dev.msfjarvis.aps.passgen.diceware.RandomIntGenerator -import java.io.InputStream -import java.security.SecureRandom -import javax.inject.Qualifier - -@Module -@InstallIn(FragmentComponent::class) -object DicewareModule { - - @Provides - fun provideDicewareGenerator( - die: Die, - @WordlistQualifier wordList: InputStream, - ): DicewarePassphraseGenerator { - return DicewarePassphraseGenerator(die, wordList) - } - - @Provides - fun provideDie( - intGenerator: RandomIntGenerator, - ): Die { - return Die(6, intGenerator) - } - - @Provides - fun provideRandomIntGenerator(): RandomIntGenerator { - return RandomIntGenerator { range -> - SecureRandom().nextInt(range.last).coerceAtLeast(range.first) - } - } - - @[Provides WordlistQualifier] - fun provideDefaultWordList(@ApplicationContext context: Context): InputStream { - return context.resources.openRawResource( - dev.msfjarvis.aps.passgen.diceware.R.raw.diceware_wordlist - ) - } -} - -@Qualifier annotation class WordlistQualifier diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/totp/TotpModule.kt b/app/src/main/java/dev/msfjarvis/aps/injection/totp/TotpModule.kt deleted file mode 100644 index 859559cd..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/injection/totp/TotpModule.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.injection.totp - -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityComponent -import dev.msfjarvis.aps.util.totp.TotpFinder -import dev.msfjarvis.aps.util.totp.UriTotpFinder - -@Module -@InstallIn(ActivityComponent::class) -interface TotpModule { - @Binds fun UriTotpFinder.bind(): TotpFinder -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/adapters/FieldItemAdapter.kt b/app/src/main/java/dev/msfjarvis/aps/ui/adapters/FieldItemAdapter.kt deleted file mode 100644 index ea96f961..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/adapters/FieldItemAdapter.kt +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.adapters - -import android.text.method.PasswordTransformationMethod -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.textfield.TextInputLayout -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.passfile.Totp -import dev.msfjarvis.aps.data.password.FieldItem -import dev.msfjarvis.aps.databinding.ItemFieldBinding - -class FieldItemAdapter( - private var fieldItemList: List, - private val showPassword: Boolean, - private val copyTextToClipboard: (text: String?) -> Unit, -) : RecyclerView.Adapter() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldItemViewHolder { - val binding = ItemFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return FieldItemViewHolder(binding.root, binding) - } - - override fun onBindViewHolder(holder: FieldItemViewHolder, position: Int) { - holder.bind(fieldItemList[position], showPassword, copyTextToClipboard) - } - - override fun getItemCount(): Int { - return fieldItemList.size - } - - fun updateOTPCode(totp: Totp) { - var otpItemPosition = -1 - fieldItemList = - fieldItemList.mapIndexed { position, item -> - if (item.key.startsWith(FieldItem.ItemType.OTP.type, true)) { - otpItemPosition = position - return@mapIndexed FieldItem.createOtpField(totp) - } - - return@mapIndexed item - } - - notifyItemChanged(otpItemPosition) - } - - class FieldItemViewHolder(itemView: View, val binding: ItemFieldBinding) : - RecyclerView.ViewHolder(itemView) { - - fun bind(fieldItem: FieldItem, showPassword: Boolean, copyTextToClipboard: (String?) -> Unit) { - with(binding) { - itemText.hint = fieldItem.key - itemTextContainer.hint = fieldItem.key - itemText.setText(fieldItem.value) - - when (fieldItem.action) { - FieldItem.ActionType.COPY -> { - itemTextContainer.apply { - endIconDrawable = - ContextCompat.getDrawable(itemView.context, R.drawable.ic_content_copy) - endIconMode = TextInputLayout.END_ICON_CUSTOM - setEndIconOnClickListener { copyTextToClipboard(itemText.text.toString()) } - } - itemText.transformationMethod = null - } - FieldItem.ActionType.HIDE -> { - itemTextContainer.apply { - endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE - setOnClickListener { copyTextToClipboard(itemText.text.toString()) } - } - itemText.apply { - transformationMethod = - if (!showPassword) { - PasswordTransformationMethod.getInstance() - } else { - null - } - setOnClickListener { copyTextToClipboard(itemText.text.toString()) } - } - } - } - } - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/adapters/PasswordItemRecyclerAdapter.kt b/app/src/main/java/dev/msfjarvis/aps/ui/adapters/PasswordItemRecyclerAdapter.kt deleted file mode 100644 index be0267c4..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/adapters/PasswordItemRecyclerAdapter.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.ui.adapters - -import android.text.SpannableString -import android.text.style.RelativeSizeSpan -import android.view.MotionEvent -import android.view.View -import androidx.appcompat.widget.AppCompatImageView -import androidx.appcompat.widget.AppCompatTextView -import androidx.recyclerview.selection.ItemDetailsLookup -import androidx.recyclerview.selection.Selection -import androidx.recyclerview.widget.RecyclerView -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.password.PasswordItem -import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryAdapter -import dev.msfjarvis.aps.util.viewmodel.stableId -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -open class PasswordItemRecyclerAdapter(coroutineScope: CoroutineScope) : - SearchableRepositoryAdapter( - R.layout.password_row_layout, - ::PasswordItemViewHolder, - coroutineScope, - PasswordItemViewHolder::bind, - ) { - - fun makeSelectable(recyclerView: RecyclerView) { - makeSelectable(recyclerView, ::PasswordItemDetailsLookup) - } - - override fun onItemClicked( - listener: (holder: PasswordItemViewHolder, item: PasswordItem) -> Unit - ): PasswordItemRecyclerAdapter { - return super.onItemClicked(listener) as PasswordItemRecyclerAdapter - } - - override fun onSelectionChanged( - listener: (selection: Selection) -> Unit - ): PasswordItemRecyclerAdapter { - return super.onSelectionChanged(listener) as PasswordItemRecyclerAdapter - } - - class PasswordItemViewHolder(view: View) : RecyclerView.ViewHolder(view) { - - private val name: AppCompatTextView = itemView.findViewById(R.id.label) - private val childCount: AppCompatTextView = itemView.findViewById(R.id.child_count) - private val folderIndicator: AppCompatImageView = itemView.findViewById(R.id.folder_indicator) - lateinit var itemDetails: ItemDetailsLookup.ItemDetails - - suspend fun bind(item: PasswordItem) { - val parentPath = item.fullPathToParent.replace("(^/)|(/$)".toRegex(), "") - val source = - if (parentPath.isNotEmpty()) { - "$parentPath\n$item" - } else { - "$item" - } - val spannable = SpannableString(source) - spannable.setSpan(RelativeSizeSpan(0.7f), 0, parentPath.length, 0) - name.text = spannable - if (item.type == PasswordItem.TYPE_CATEGORY) { - folderIndicator.visibility = View.VISIBLE - val count = - withContext(Dispatchers.IO) { - item.file.listFiles { path -> path.isDirectory || path.extension == "gpg" }?.size ?: 0 - } - childCount.visibility = if (count > 0) View.VISIBLE else View.GONE - childCount.text = "$count" - } else { - childCount.visibility = View.GONE - folderIndicator.visibility = View.GONE - } - itemDetails = - object : ItemDetailsLookup.ItemDetails() { - override fun getPosition() = absoluteAdapterPosition - override fun getSelectionKey() = item.stableId - } - } - } - - class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) : - ItemDetailsLookup() { - - override fun getItemDetails(event: MotionEvent): ItemDetails? { - val view = recyclerView.findChildViewUnder(event.x, event.y) ?: return null - return (recyclerView.getChildViewHolder(view) as PasswordItemViewHolder).itemDetails - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt deleted file mode 100644 index b5052049..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt +++ /dev/null @@ -1,269 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.ui.autofill - -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.content.IntentSender -import android.os.Build -import android.os.Bundle -import android.view.autofill.AutofillManager -import android.widget.Toast -import androidx.activity.result.IntentSenderRequest -import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult -import androidx.annotation.RequiresApi -import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.lifecycleScope -import com.github.androidpasswordstore.autofillparser.AutofillAction -import com.github.androidpasswordstore.autofillparser.Credentials -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.onSuccess -import com.github.michaelbull.result.runCatching -import dagger.hilt.android.AndroidEntryPoint -import dev.msfjarvis.aps.data.passfile.PasswordEntry -import dev.msfjarvis.aps.util.autofill.AutofillPreferences -import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder -import dev.msfjarvis.aps.util.autofill.DirectoryStructure -import dev.msfjarvis.aps.util.extensions.OPENPGP_PROVIDER -import dev.msfjarvis.aps.util.extensions.asLog -import java.io.ByteArrayOutputStream -import java.io.File -import java.io.InputStream -import java.io.OutputStream -import javax.inject.Inject -import kotlin.coroutines.Continuation -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.LogPriority.ERROR -import logcat.logcat -import me.msfjarvis.openpgpktx.util.OpenPgpApi -import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection -import org.openintents.openpgp.IOpenPgpService2 -import org.openintents.openpgp.OpenPgpError - -@RequiresApi(26) -@AndroidEntryPoint -class AutofillDecryptActivity : AppCompatActivity() { - - companion object { - - private const val EXTRA_FILE_PATH = "dev.msfjarvis.aps.autofill.oreo.EXTRA_FILE_PATH" - private const val EXTRA_SEARCH_ACTION = "dev.msfjarvis.aps.autofill.oreo.EXTRA_SEARCH_ACTION" - - private var decryptFileRequestCode = 1 - - fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent { - return Intent(context, AutofillDecryptActivity::class.java).apply { - putExtras(forwardedExtras) - putExtra(EXTRA_SEARCH_ACTION, true) - putExtra(EXTRA_FILE_PATH, file.absolutePath) - } - } - - fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender { - val intent = - Intent(context, AutofillDecryptActivity::class.java).apply { - putExtra(EXTRA_SEARCH_ACTION, false) - putExtra(EXTRA_FILE_PATH, file.absolutePath) - } - return PendingIntent.getActivity( - context, - decryptFileRequestCode++, - intent, - if (Build.VERSION.SDK_INT >= 31) { - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE - } else { - PendingIntent.FLAG_CANCEL_CURRENT - }, - ) - .intentSender - } - } - - @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory - - private val decryptInteractionRequiredAction = - registerForActivityResult(StartIntentSenderForResult()) { result -> - if (continueAfterUserInteraction != null) { - val data = result.data - if (result.resultCode == RESULT_OK && data != null) { - continueAfterUserInteraction?.resume(data) - } else { - continueAfterUserInteraction?.resumeWithException( - Exception("OpenPgpApi ACTION_DECRYPT_VERIFY failed to continue after user interaction") - ) - } - continueAfterUserInteraction = null - } - } - - private var continueAfterUserInteraction: Continuation? = null - private lateinit var directoryStructure: DirectoryStructure - - override fun onStart() { - super.onStart() - val filePath = - intent?.getStringExtra(EXTRA_FILE_PATH) - ?: run { - logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_FILE_PATH" } - finish() - return - } - val clientState = - intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) - ?: run { - logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } - finish() - return - } - val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!! - val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match - directoryStructure = AutofillPreferences.directoryStructure(this) - logcat { action.toString() } - lifecycleScope.launch { - val credentials = decryptCredential(File(filePath)) - if (credentials == null) { - setResult(RESULT_CANCELED) - } else { - val fillInDataset = - AutofillResponseBuilder.makeFillInDataset( - this@AutofillDecryptActivity, - credentials, - clientState, - action - ) - withContext(Dispatchers.Main) { - setResult( - RESULT_OK, - Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) } - ) - } - } - withContext(Dispatchers.Main) { finish() } - } - } - - private suspend fun executeOpenPgpApi( - data: Intent, - input: InputStream, - output: OutputStream - ): Intent { - var openPgpServiceConnection: OpenPgpServiceConnection? = null - val openPgpService = - suspendCoroutine { cont -> - openPgpServiceConnection = - OpenPgpServiceConnection( - this, - OPENPGP_PROVIDER, - object : OpenPgpServiceConnection.OnBound { - override fun onBound(service: IOpenPgpService2) { - cont.resume(service) - } - - override fun onError(e: Exception) { - cont.resumeWithException(e) - } - } - ) - .also { it.bindToService() } - } - return OpenPgpApi(this, openPgpService).executeApi(data, input, output).also { - openPgpServiceConnection?.unbindFromService() - } - } - - private suspend fun decryptCredential(file: File, resumeIntent: Intent? = null): Credentials? { - val command = resumeIntent ?: Intent().apply { action = OpenPgpApi.ACTION_DECRYPT_VERIFY } - runCatching { file.inputStream() } - .onFailure { e -> - logcat(ERROR) { e.asLog("File to decrypt not found") } - return null - } - .onSuccess { encryptedInput -> - val decryptedOutput = ByteArrayOutputStream() - runCatching { executeOpenPgpApi(command, encryptedInput, decryptedOutput) } - .onFailure { e -> - logcat(ERROR) { e.asLog("OpenPgpApi ACTION_DECRYPT_VERIFY failed") } - return null - } - .onSuccess { result -> - return when ( - val resultCode = - result.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR) - ) { - OpenPgpApi.RESULT_CODE_SUCCESS -> { - runCatching { - val entry = - withContext(Dispatchers.IO) { - @Suppress("BlockingMethodInNonBlockingContext") - passwordEntryFactory.create(decryptedOutput.toByteArray()) - } - AutofillPreferences.credentialsFromStoreEntry( - this, - file, - entry, - directoryStructure - ) - } - .getOrElse { e -> - logcat(ERROR) { e.asLog("Failed to parse password entry") } - return null - } - } - OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { - val pendingIntent: PendingIntent = - result.getParcelableExtra(OpenPgpApi.RESULT_INTENT)!! - runCatching { - val intentToResume = - withContext(Dispatchers.Main) { - suspendCoroutine { cont -> - continueAfterUserInteraction = cont - decryptInteractionRequiredAction.launch( - IntentSenderRequest.Builder(pendingIntent.intentSender).build() - ) - } - } - decryptCredential(file, intentToResume) - } - .getOrElse { e -> - logcat(ERROR) { - e.asLog("OpenPgpApi ACTION_DECRYPT_VERIFY failed with user interaction") - } - return null - } - } - OpenPgpApi.RESULT_CODE_ERROR -> { - val error = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR) - if (error != null) { - withContext(Dispatchers.Main) { - Toast.makeText( - applicationContext, - "Error from OpenKeyChain: ${error.message}", - Toast.LENGTH_LONG - ) - .show() - } - logcat(ERROR) { - "OpenPgpApi ACTION_DECRYPT_VERIFY failed (${error.errorId}): ${error.message}" - } - } - null - } - else -> { - logcat(ERROR) { "Unrecognized OpenPgpApi result: $resultCode" } - null - } - } - } - } - return null - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivityV2.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivityV2.kt deleted file mode 100644 index 4fd8f026..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivityV2.kt +++ /dev/null @@ -1,180 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.ui.autofill - -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.content.IntentSender -import android.os.Build -import android.os.Bundle -import android.view.autofill.AutofillManager -import androidx.annotation.RequiresApi -import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.lifecycleScope -import com.github.androidpasswordstore.autofillparser.AutofillAction -import com.github.androidpasswordstore.autofillparser.Credentials -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.onSuccess -import com.github.michaelbull.result.runCatching -import dagger.hilt.android.AndroidEntryPoint -import dev.msfjarvis.aps.data.crypto.CryptoRepository -import dev.msfjarvis.aps.data.passfile.PasswordEntry -import dev.msfjarvis.aps.ui.crypto.PasswordDialog -import dev.msfjarvis.aps.util.autofill.AutofillPreferences -import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder -import dev.msfjarvis.aps.util.autofill.DirectoryStructure -import dev.msfjarvis.aps.util.extensions.asLog -import java.io.ByteArrayOutputStream -import java.io.File -import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.LogPriority.ERROR -import logcat.logcat - -@RequiresApi(26) -@AndroidEntryPoint -class AutofillDecryptActivityV2 : AppCompatActivity() { - - companion object { - - private const val EXTRA_FILE_PATH = "dev.msfjarvis.aps.autofill.oreo.EXTRA_FILE_PATH" - private const val EXTRA_SEARCH_ACTION = "dev.msfjarvis.aps.autofill.oreo.EXTRA_SEARCH_ACTION" - - private var decryptFileRequestCode = 1 - - fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent { - return Intent(context, AutofillDecryptActivityV2::class.java).apply { - putExtras(forwardedExtras) - putExtra(EXTRA_SEARCH_ACTION, true) - putExtra(EXTRA_FILE_PATH, file.absolutePath) - } - } - - fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender { - val intent = - Intent(context, AutofillDecryptActivityV2::class.java).apply { - putExtra(EXTRA_SEARCH_ACTION, false) - putExtra(EXTRA_FILE_PATH, file.absolutePath) - } - return PendingIntent.getActivity( - context, - decryptFileRequestCode++, - intent, - if (Build.VERSION.SDK_INT >= 31) { - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE - } else { - PendingIntent.FLAG_CANCEL_CURRENT - }, - ) - .intentSender - } - } - - @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory - @Inject lateinit var repository: CryptoRepository - - private lateinit var directoryStructure: DirectoryStructure - - override fun onStart() { - super.onStart() - val filePath = - intent?.getStringExtra(EXTRA_FILE_PATH) - ?: run { - logcat(ERROR) { "AutofillDecryptActivityV2 started without EXTRA_FILE_PATH" } - finish() - return - } - val clientState = - intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) - ?: run { - logcat(ERROR) { "AutofillDecryptActivityV2 started without EXTRA_CLIENT_STATE" } - finish() - return - } - val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!! - val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match - directoryStructure = AutofillPreferences.directoryStructure(this) - logcat { action.toString() } - val dialog = PasswordDialog() - lifecycleScope.launch { - withContext(Dispatchers.Main) { - dialog.password.collectLatest { value -> - if (value != null) { - decrypt(File(filePath), clientState, action, value) - } - } - } - } - dialog.show(supportFragmentManager, "PASSWORD_DIALOG") - } - - private suspend fun decrypt( - filePath: File, - clientState: Bundle, - action: AutofillAction, - password: String, - ) { - val credentials = decryptCredential(filePath, password) - if (credentials == null) { - setResult(RESULT_CANCELED) - } else { - val fillInDataset = - AutofillResponseBuilder.makeFillInDataset( - this@AutofillDecryptActivityV2, - credentials, - clientState, - action - ) - withContext(Dispatchers.Main) { - setResult( - RESULT_OK, - Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) } - ) - } - } - withContext(Dispatchers.Main) { finish() } - } - - private suspend fun decryptCredential(file: File, password: String): Credentials? { - runCatching { file.readBytes().inputStream() } - .onFailure { e -> - logcat(ERROR) { e.asLog("File to decrypt not found") } - return null - } - .onSuccess { encryptedInput -> - runCatching { - withContext(Dispatchers.IO) { - val outputStream = ByteArrayOutputStream() - repository.decrypt( - password, - encryptedInput, - outputStream, - ) - outputStream - } - } - .onFailure { e -> - logcat(ERROR) { e.asLog("Decryption failed") } - return null - } - .onSuccess { result -> - return runCatching { - val entry = passwordEntryFactory.create(result.toByteArray()) - AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure) - } - .getOrElse { e -> - logcat(ERROR) { e.asLog("Failed to parse password entry") } - return null - } - } - } - return null - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterView.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterView.kt deleted file mode 100644 index 89f1a733..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterView.kt +++ /dev/null @@ -1,241 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.ui.autofill - -import android.annotation.TargetApi -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.content.IntentSender -import android.os.Build -import android.os.Bundle -import android.view.View -import android.view.autofill.AutofillManager -import android.widget.TextView -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.core.text.bold -import androidx.core.text.buildSpannedString -import androidx.core.text.underline -import androidx.core.widget.addTextChangedListener -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import com.github.androidpasswordstore.autofillparser.FormOrigin -import dagger.hilt.android.AndroidEntryPoint -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.password.PasswordItem -import dev.msfjarvis.aps.databinding.ActivityOreoAutofillFilterBinding -import dev.msfjarvis.aps.util.autofill.AutofillMatcher -import dev.msfjarvis.aps.util.autofill.AutofillPreferences -import dev.msfjarvis.aps.util.autofill.DirectoryStructure -import dev.msfjarvis.aps.util.extensions.viewBinding -import dev.msfjarvis.aps.util.features.Feature -import dev.msfjarvis.aps.util.features.Features -import dev.msfjarvis.aps.util.viewmodel.FilterMode -import dev.msfjarvis.aps.util.viewmodel.ListMode -import dev.msfjarvis.aps.util.viewmodel.SearchMode -import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryAdapter -import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryViewModel -import javax.inject.Inject -import logcat.LogPriority.ERROR -import logcat.logcat - -@TargetApi(26) -@AndroidEntryPoint -class AutofillFilterView : AppCompatActivity() { - - companion object { - - private const val HEIGHT_PERCENTAGE = 0.9 - private const val WIDTH_PERCENTAGE = 0.75 - - private const val EXTRA_FORM_ORIGIN_WEB = - "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_WEB" - private const val EXTRA_FORM_ORIGIN_APP = - "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP" - private var matchAndDecryptFileRequestCode = 1 - - fun makeMatchAndDecryptFileIntentSender( - context: Context, - formOrigin: FormOrigin - ): IntentSender { - val intent = - Intent(context, AutofillFilterView::class.java).apply { - when (formOrigin) { - is FormOrigin.Web -> putExtra(EXTRA_FORM_ORIGIN_WEB, formOrigin.identifier) - is FormOrigin.App -> putExtra(EXTRA_FORM_ORIGIN_APP, formOrigin.identifier) - } - } - return PendingIntent.getActivity( - context, - matchAndDecryptFileRequestCode++, - intent, - if (Build.VERSION.SDK_INT >= 31) { - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE - } else { - PendingIntent.FLAG_CANCEL_CURRENT - }, - ) - .intentSender - } - } - - @Inject lateinit var features: Features - private lateinit var formOrigin: FormOrigin - private lateinit var directoryStructure: DirectoryStructure - private val binding by viewBinding(ActivityOreoAutofillFilterBinding::inflate) - - private val model: SearchableRepositoryViewModel by viewModels { - ViewModelProvider.AndroidViewModelFactory(application) - } - - private val decryptAction = - registerForActivityResult(StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - setResult(RESULT_OK, result.data) - } - finish() - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - setFinishOnTouchOutside(true) - - val params = window.attributes - params.height = (HEIGHT_PERCENTAGE * resources.displayMetrics.heightPixels).toInt() - params.width = (WIDTH_PERCENTAGE * resources.displayMetrics.widthPixels).toInt() - window.attributes = params - - if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true) { - logcat(ERROR) { "AutofillFilterActivity started without EXTRA_CLIENT_STATE" } - finish() - return - } - formOrigin = - when { - intent?.hasExtra(EXTRA_FORM_ORIGIN_WEB) == true -> { - FormOrigin.Web(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_WEB)!!) - } - intent?.hasExtra(EXTRA_FORM_ORIGIN_APP) == true -> { - FormOrigin.App(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_APP)!!) - } - else -> { - logcat(ERROR) { - "AutofillFilterActivity started without EXTRA_FORM_ORIGIN_WEB or EXTRA_FORM_ORIGIN_APP" - } - finish() - return - } - } - directoryStructure = AutofillPreferences.directoryStructure(this) - - supportActionBar?.hide() - bindUI() - updateSearch() - setResult(RESULT_CANCELED) - } - - private fun bindUI() { - with(binding) { - rvPassword.apply { - adapter = - SearchableRepositoryAdapter( - R.layout.oreo_autofill_filter_row, - ::PasswordViewHolder, - lifecycleScope, - ) { item -> - val file = item.file.relativeTo(item.rootDir) - val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file) - val identifier = directoryStructure.getIdentifierFor(file) - val accountPart = directoryStructure.getAccountPartFor(file) - check(identifier != null || accountPart != null) { - "At least one of identifier and accountPart should always be non-null" - } - title.text = - if (identifier != null) { - buildSpannedString { - if (pathToIdentifier != null) append("$pathToIdentifier/") - bold { underline { append(identifier) } } - } - } else { - accountPart - } - subtitle.apply { - if (identifier != null && accountPart != null) { - text = accountPart - visibility = View.VISIBLE - } else { - visibility = View.GONE - } - } - } - .onItemClicked { _, item -> decryptAndFill(item) } - layoutManager = LinearLayoutManager(context) - } - search.apply { - val initialSearch = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false) - setText(initialSearch, TextView.BufferType.EDITABLE) - addTextChangedListener { updateSearch() } - } - origin.text = buildSpannedString { - append(getString(R.string.oreo_autofill_select_and_fill_into)) - append("\n") - bold { append(formOrigin.getPrettyIdentifier(applicationContext, untrusted = true)) } - } - strictDomainSearch.apply { - visibility = if (formOrigin is FormOrigin.Web) View.VISIBLE else View.GONE - isChecked = formOrigin is FormOrigin.Web - setOnCheckedChangeListener { _, _ -> updateSearch() } - } - shouldMatch.text = - getString( - R.string.oreo_autofill_match_with, - formOrigin.getPrettyIdentifier(applicationContext) - ) - model.searchResult.observe(this@AutofillFilterView) { result -> - val list = result.passwordItems - (rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) { - rvPassword.scrollToPosition(0) - } - // Switch RecyclerView out for a "no results" message if the new list is empty and - // the message is not yet shown (and vice versa). - if ( - (list.isEmpty() && rvPasswordSwitcher.nextView.id == rvPasswordEmpty.id) || - (list.isNotEmpty() && rvPasswordSwitcher.nextView.id == rvPassword.id) - ) { - rvPasswordSwitcher.showNext() - } - } - } - } - - private fun updateSearch() { - model.search( - binding.search.text.toString().trim(), - filterMode = - if (binding.strictDomainSearch.isChecked) FilterMode.StrictDomain else FilterMode.Fuzzy, - searchMode = SearchMode.RecursivelyInSubdirectories, - listMode = ListMode.FilesOnly - ) - } - - private fun decryptAndFill(item: PasswordItem) { - if (binding.shouldClear.isChecked) - AutofillMatcher.clearMatchesFor(applicationContext, formOrigin) - if (binding.shouldMatch.isChecked) - AutofillMatcher.addMatchFor(applicationContext, formOrigin, item.file) - // intent?.extras? is checked to be non-null in onCreate - decryptAction.launch( - if (features.isEnabled(Feature.EnablePGPainlessBackend)) { - AutofillDecryptActivityV2.makeDecryptFileIntent(item.file, intent!!.extras!!, this) - } else { - AutofillDecryptActivity.makeDecryptFileIntent(item.file, intent!!.extras!!, this) - } - ) - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillPublisherChangedActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillPublisherChangedActivity.kt deleted file mode 100644 index 85911815..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillPublisherChangedActivity.kt +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.ui.autofill - -import android.annotation.TargetApi -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.content.IntentSender -import android.content.pm.PackageManager -import android.os.Build -import android.os.Bundle -import android.service.autofill.FillResponse -import android.text.format.DateUtils -import android.view.View -import android.view.autofill.AutofillManager -import androidx.appcompat.app.AppCompatActivity -import com.github.androidpasswordstore.autofillparser.FormOrigin -import com.github.androidpasswordstore.autofillparser.computeCertificatesHash -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.databinding.ActivityOreoAutofillPublisherChangedBinding -import dev.msfjarvis.aps.util.autofill.AutofillMatcher -import dev.msfjarvis.aps.util.autofill.AutofillPublisherChangedException -import dev.msfjarvis.aps.util.extensions.asLog -import dev.msfjarvis.aps.util.extensions.viewBinding -import logcat.LogPriority.ERROR -import logcat.logcat - -@TargetApi(26) -class AutofillPublisherChangedActivity : AppCompatActivity() { - - companion object { - - private const val EXTRA_APP_PACKAGE = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_APP_PACKAGE" - private const val EXTRA_FILL_RESPONSE_AFTER_RESET = - "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FILL_RESPONSE_AFTER_RESET" - private var publisherChangedRequestCode = 1 - - fun makePublisherChangedIntentSender( - context: Context, - publisherChangedException: AutofillPublisherChangedException, - fillResponseAfterReset: FillResponse?, - ): IntentSender { - val intent = - Intent(context, AutofillPublisherChangedActivity::class.java).apply { - putExtra(EXTRA_APP_PACKAGE, publisherChangedException.formOrigin.identifier) - putExtra(EXTRA_FILL_RESPONSE_AFTER_RESET, fillResponseAfterReset) - } - return PendingIntent.getActivity( - context, - publisherChangedRequestCode++, - intent, - if (Build.VERSION.SDK_INT >= 31) { - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE - } else { - PendingIntent.FLAG_CANCEL_CURRENT - }, - ) - .intentSender - } - } - - private lateinit var appPackage: String - private val binding by viewBinding(ActivityOreoAutofillPublisherChangedBinding::inflate) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - setFinishOnTouchOutside(true) - - appPackage = - intent.getStringExtra(EXTRA_APP_PACKAGE) - ?: run { - logcat(ERROR) { "AutofillPublisherChangedActivity started without EXTRA_PACKAGE_NAME" } - finish() - return - } - supportActionBar?.hide() - showPackageInfo() - with(binding) { - okButton.setOnClickListener { finish() } - advancedButton.setOnClickListener { - advancedButton.visibility = View.GONE - warningAppAdvancedInfo.visibility = View.VISIBLE - resetButton.visibility = View.VISIBLE - } - resetButton.setOnClickListener { - AutofillMatcher.clearMatchesFor( - this@AutofillPublisherChangedActivity, - FormOrigin.App(appPackage) - ) - val fillResponse = intent.getParcelableExtra(EXTRA_FILL_RESPONSE_AFTER_RESET) - setResult( - RESULT_OK, - Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse) } - ) - finish() - } - } - } - - private fun showPackageInfo() { - runCatching { - with(binding) { - val packageInfo = packageManager.getPackageInfo(appPackage, PackageManager.GET_META_DATA) - val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime) - warningAppInstallDate.text = - getString(R.string.oreo_autofill_warning_publisher_install_time, installTime) - val appInfo = packageManager.getApplicationInfo(appPackage, PackageManager.GET_META_DATA) - warningAppName.text = - getString( - R.string.oreo_autofill_warning_publisher_app_name, - packageManager.getApplicationLabel(appInfo) - ) - - val currentHash = - computeCertificatesHash(this@AutofillPublisherChangedActivity, appPackage) - warningAppAdvancedInfo.text = - getString( - R.string.oreo_autofill_warning_publisher_advanced_info_template, - appPackage, - currentHash - ) - } - } - .onFailure { e -> - logcat(ERROR) { e.asLog("Failed to retrieve package info for $appPackage") } - finish() - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillSaveActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillSaveActivity.kt deleted file mode 100644 index a963836e..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillSaveActivity.kt +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.ui.autofill - -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.content.IntentSender -import android.os.Bundle -import android.view.autofill.AutofillManager -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.annotation.RequiresApi -import androidx.appcompat.app.AppCompatActivity -import androidx.core.os.bundleOf -import com.github.androidpasswordstore.autofillparser.AutofillAction -import com.github.androidpasswordstore.autofillparser.Credentials -import com.github.androidpasswordstore.autofillparser.FormOrigin -import dagger.hilt.android.AndroidEntryPoint -import dev.msfjarvis.aps.data.repo.PasswordRepository -import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivity -import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivityV2 -import dev.msfjarvis.aps.util.autofill.AutofillMatcher -import dev.msfjarvis.aps.util.autofill.AutofillPreferences -import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder -import dev.msfjarvis.aps.util.extensions.unsafeLazy -import dev.msfjarvis.aps.util.features.Feature -import dev.msfjarvis.aps.util.features.Features -import java.io.File -import javax.inject.Inject -import logcat.LogPriority.ERROR -import logcat.logcat - -@RequiresApi(26) -@AndroidEntryPoint -class AutofillSaveActivity : AppCompatActivity() { - - companion object { - - private const val EXTRA_FOLDER_NAME = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FOLDER_NAME" - private const val EXTRA_PASSWORD = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_PASSWORD" - private const val EXTRA_NAME = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_NAME" - private const val EXTRA_SHOULD_MATCH_APP = - "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_APP" - private const val EXTRA_SHOULD_MATCH_WEB = - "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_WEB" - private const val EXTRA_GENERATE_PASSWORD = - "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_GENERATE_PASSWORD" - - private var saveRequestCode = 1 - - fun makeSaveIntentSender( - context: Context, - credentials: Credentials?, - formOrigin: FormOrigin - ): IntentSender { - val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false) - // Prevent directory traversals - val sanitizedIdentifier = - identifier.replace('\\', '_').replace('/', '_').trimStart('.').takeUnless { it.isBlank() } - ?: formOrigin.identifier - val directoryStructure = AutofillPreferences.directoryStructure(context) - val folderName = - directoryStructure.getSaveFolderName( - sanitizedIdentifier = sanitizedIdentifier, - username = credentials?.username - ) - val fileName = - directoryStructure.getSaveFileName( - username = credentials?.username, - identifier = identifier - ) - val intent = - Intent(context, AutofillSaveActivity::class.java).apply { - putExtras( - bundleOf( - EXTRA_FOLDER_NAME to folderName, - EXTRA_NAME to fileName, - EXTRA_PASSWORD to credentials?.password, - EXTRA_SHOULD_MATCH_APP to - formOrigin.identifier.takeIf { formOrigin is FormOrigin.App }, - EXTRA_SHOULD_MATCH_WEB to - formOrigin.identifier.takeIf { formOrigin is FormOrigin.Web }, - EXTRA_GENERATE_PASSWORD to (credentials == null) - ) - ) - } - return PendingIntent.getActivity( - context, - saveRequestCode++, - intent, - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - .intentSender - } - } - - private val formOrigin by unsafeLazy { - val shouldMatchApp: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_APP) - val shouldMatchWeb: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_WEB) - if (shouldMatchApp != null && shouldMatchWeb == null) { - FormOrigin.App(shouldMatchApp) - } else if (shouldMatchApp == null && shouldMatchWeb != null) { - FormOrigin.Web(shouldMatchWeb) - } else { - null - } - } - - @Inject lateinit var features: Features - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val repo = PasswordRepository.getRepositoryDirectory() - val creationActivity = - if (features.isEnabled(Feature.EnablePGPainlessBackend)) - PasswordCreationActivityV2::class.java - else PasswordCreationActivity::class.java - val saveIntent = - Intent(this, creationActivity).apply { - putExtras( - bundleOf( - "REPO_PATH" to repo.absolutePath, - "FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)!!).absolutePath, - PasswordCreationActivity.EXTRA_FILE_NAME to intent.getStringExtra(EXTRA_NAME), - PasswordCreationActivity.EXTRA_PASSWORD to intent.getStringExtra(EXTRA_PASSWORD), - PasswordCreationActivity.EXTRA_GENERATE_PASSWORD to - intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) - ) - ) - } - registerForActivityResult(StartActivityForResult()) { result -> - val data = result.data - if (result.resultCode == RESULT_OK && data != null) { - val createdPath = data.getStringExtra("CREATED_FILE")!! - formOrigin?.let { AutofillMatcher.addMatchFor(this, it, File(createdPath)) } - val password = data.getStringExtra("PASSWORD") - val resultIntent = - if (password != null) { - // Password was generated and should be filled into a form. - val username = data.getStringExtra("USERNAME") - val clientState = - intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) - ?: run { - logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } - finish() - return@registerForActivityResult - } - val credentials = Credentials(username, password, null) - val fillInDataset = - AutofillResponseBuilder.makeFillInDataset( - this, - credentials, - clientState, - AutofillAction.Generate - ) - Intent().apply { - putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) - } - } else { - // Password was extracted from a form, there is nothing to fill. - Intent() - } - setResult(RESULT_OK, resultIntent) - } else { - setResult(RESULT_CANCELED) - } - finish() - } - .launch(saveIntent) - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/PasswordViewHolder.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/PasswordViewHolder.kt deleted file mode 100644 index eacd49c3..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/PasswordViewHolder.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.ui.autofill - -import android.view.View -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView -import dev.msfjarvis.aps.R - -class PasswordViewHolder(view: View) : RecyclerView.ViewHolder(view) { - - val title: TextView = itemView.findViewById(R.id.title) - val subtitle: TextView = itemView.findViewById(R.id.subtitle) -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt deleted file mode 100644 index 758a927d..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt +++ /dev/null @@ -1,290 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.crypto - -import android.app.PendingIntent -import android.content.ClipData -import android.content.Intent -import android.content.IntentSender -import android.content.SharedPreferences -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.view.WindowManager -import androidx.annotation.CallSuper -import androidx.annotation.StringRes -import androidx.appcompat.app.AppCompatActivity -import com.github.michaelbull.result.getOr -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import dagger.hilt.android.AndroidEntryPoint -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.injection.prefs.SettingsPreferences -import dev.msfjarvis.aps.util.extensions.OPENPGP_PROVIDER -import dev.msfjarvis.aps.util.extensions.asLog -import dev.msfjarvis.aps.util.extensions.clipboard -import dev.msfjarvis.aps.util.extensions.getString -import dev.msfjarvis.aps.util.extensions.snackbar -import dev.msfjarvis.aps.util.extensions.unsafeLazy -import dev.msfjarvis.aps.util.features.Feature -import dev.msfjarvis.aps.util.features.Features -import dev.msfjarvis.aps.util.services.ClipboardService -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import java.io.File -import javax.inject.Inject -import logcat.LogPriority.ERROR -import logcat.LogPriority.INFO -import logcat.logcat -import me.msfjarvis.openpgpktx.util.OpenPgpApi -import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection -import org.openintents.openpgp.IOpenPgpService2 -import org.openintents.openpgp.OpenPgpError - -@Suppress("Registered") -@AndroidEntryPoint -open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { - - /** Full path to the repository */ - val repoPath by unsafeLazy { intent.getStringExtra("REPO_PATH")!! } - - /** Full path to the password file being worked on */ - val fullPath by unsafeLazy { intent.getStringExtra("FILE_PATH")!! } - - /** - * Name of the password file - * - * Converts personal/auth.foo.org/john_doe@example.org.gpg to john_doe.example.org - */ - val name: String by unsafeLazy { File(fullPath).nameWithoutExtension } - - /** [SharedPreferences] instance used by subclasses to persist settings */ - @SettingsPreferences @Inject lateinit var settings: SharedPreferences - - @Inject lateinit var features: Features - - /** - * Handle to the [OpenPgpApi] instance that is used by subclasses to interface with OpenKeychain. - */ - private var serviceConnection: OpenPgpServiceConnection? = null - var api: OpenPgpApi? = null - - /** - * A [OpenPgpServiceConnection.OnBound] instance for the last listener that we wish to bind with - * in case the previous attempt was cancelled due to missing [OPENPGP_PROVIDER] package. - */ - private var previousListener: OpenPgpServiceConnection.OnBound? = null - - /** - * [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots or - * recent apps screen. - */ - @CallSuper - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) - } - - /** - * [onDestroy] handles unbinding from the OpenPgp service linked with [serviceConnection]. This is - * annotated with [CallSuper] because it's critical to unbind the service to ensure we're not - * leaking things. - */ - @CallSuper - override fun onDestroy() { - super.onDestroy() - serviceConnection?.unbindFromService() - previousListener = null - } - - /** - * [onResume] controls the flow for resumption of a PGP operation that was previously interrupted - * by the [OPENPGP_PROVIDER] package being missing. - */ - override fun onResume() { - super.onResume() - previousListener?.let { bindToOpenKeychain(it) } - } - - /** - * Sets up [api] once the service is bound. Downstream consumers must call super this to - * initialize [api] - */ - @CallSuper - override fun onBound(service: IOpenPgpService2) { - api = OpenPgpApi(this, service) - } - - /** - * Mandatory error handling from [OpenPgpServiceConnection.OnBound]. All subclasses must handle - * their own errors, and hence this class simply logs and rethrows. Subclasses Must NOT call - * super. - */ - override fun onError(e: Exception) { - logcat(ERROR) { e.asLog("Callers must handle their own exceptions") } - throw e - } - - /** Method for subclasses to initiate binding with [OpenPgpServiceConnection]. */ - fun bindToOpenKeychain(onBoundListener: OpenPgpServiceConnection.OnBound) { - if (features.isEnabled(Feature.EnablePGPainlessBackend)) return - val installed = - runCatching { - packageManager.getPackageInfo(OPENPGP_PROVIDER, 0) - true - } - .getOr(false) - if (!installed) { - previousListener = onBoundListener - MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.openkeychain_not_installed_title)) - .setMessage(getString(R.string.openkeychain_not_installed_message)) - .setPositiveButton(getString(R.string.openkeychain_not_installed_google_play)) { _, _ -> - runCatching { - val intent = - Intent(Intent.ACTION_VIEW).apply { - data = Uri.parse(getString(R.string.play_deeplink_template, OPENPGP_PROVIDER)) - setPackage("com.android.vending") - } - startActivity(intent) - } - } - .setNeutralButton(getString(R.string.openkeychain_not_installed_fdroid)) { _, _ -> - runCatching { - val intent = - Intent(Intent.ACTION_VIEW).apply { - data = Uri.parse(getString(R.string.fdroid_deeplink_template, OPENPGP_PROVIDER)) - } - startActivity(intent) - } - } - .setOnCancelListener { finish() } - .show() - return - } else { - previousListener = null - serviceConnection = - OpenPgpServiceConnection(this, OPENPGP_PROVIDER, onBoundListener).also { - it.bindToService() - } - } - } - - /** - * Handle the case where OpenKeychain returns that it needs to interact with the user - * - * @param result The intent returned by OpenKeychain - */ - fun getUserInteractionRequestIntent(result: Intent): IntentSender { - logcat(INFO) { "RESULT_CODE_USER_INTERACTION_REQUIRED" } - return result.getParcelableExtra(OpenPgpApi.RESULT_INTENT)!!.intentSender - } - - /** - * Base handling of OpenKeychain errors based on the error contained in [result]. Subclasses can - * use this when they want to default to sane error handling. - */ - fun handleError(result: Intent) { - val error: OpenPgpError? = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR) - if (error != null) { - when (error.errorId) { - OpenPgpError.NO_OR_WRONG_PASSPHRASE -> { - snackbar(message = getString(R.string.openpgp_error_wrong_passphrase)) - } - OpenPgpError.NO_USER_IDS -> { - snackbar(message = getString(R.string.openpgp_error_no_user_ids)) - } - else -> { - snackbar(message = getString(R.string.openpgp_error_unknown, error.message)) - logcat(ERROR) { "onError getErrorId: ${error.errorId}" } - logcat(ERROR) { "onError getMessage: ${error.message}" } - } - } - } - } - - /** - * Copies provided [text] to the clipboard. Shows a [Snackbar] which can be disabled by passing - * [showSnackbar] as false. - */ - fun copyTextToClipboard( - text: String?, - showSnackbar: Boolean = true, - @StringRes snackbarTextRes: Int = R.string.clipboard_copied_text - ) { - val clipboard = clipboard ?: return - val clip = ClipData.newPlainText("pgp_handler_result_pm", text) - clipboard.setPrimaryClip(clip) - if (showSnackbar) { - snackbar(message = resources.getString(snackbarTextRes)) - } - } - - /** - * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] to hide - * the default [Snackbar] and starts off an instance of [ClipboardService] to provide a way of - * clearing the clipboard. - */ - fun copyPasswordToClipboard(password: String?) { - copyTextToClipboard(password, showSnackbar = false) - - val clearAfter = settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() ?: 45 - - if (clearAfter != 0) { - val service = - Intent(this, ClipboardService::class.java).apply { - action = ClipboardService.ACTION_START - putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter) - } - if (Build.VERSION.SDK_INT >= 26) { - startForegroundService(service) - } else { - startService(service) - } - snackbar(message = resources.getString(R.string.clipboard_password_toast_text, clearAfter)) - } else { - snackbar(message = resources.getString(R.string.clipboard_password_no_clear_toast_text)) - } - } - - companion object { - - private const val TAG = "APS/BasePgpActivity" - const val EXTRA_FILE_PATH = "FILE_PATH" - const val EXTRA_REPO_PATH = "REPO_PATH" - - /** Gets the relative path to the repository */ - fun getRelativePath(fullPath: String, repositoryPath: String): String = - fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") - - /** Gets the Parent path, relative to the repository */ - fun getParentPath(fullPath: String, repositoryPath: String): String { - val relativePath = getRelativePath(fullPath, repositoryPath) - val index = relativePath.lastIndexOf("/") - return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/".replace( - "/+".toRegex(), - "/" - ) - } - - /** /path/to/store/social/facebook.gpg -> social/facebook */ - @JvmStatic - fun getLongName(fullPath: String, repositoryPath: String, basename: String): String { - var relativePath = getRelativePath(fullPath, repositoryPath) - return if (relativePath.isNotEmpty() && relativePath != "/") { - // remove preceding '/' - relativePath = relativePath.substring(1) - if (relativePath.endsWith('/')) { - relativePath + basename - } else { - "$relativePath/$basename" - } - } else { - basename - } - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt deleted file mode 100644 index af9a4ddd..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt +++ /dev/null @@ -1,227 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.crypto - -import android.content.Intent -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import androidx.activity.result.IntentSenderRequest -import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult -import androidx.lifecycle.lifecycleScope -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import dagger.hilt.android.AndroidEntryPoint -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.passfile.PasswordEntry -import dev.msfjarvis.aps.data.password.FieldItem -import dev.msfjarvis.aps.databinding.DecryptLayoutBinding -import dev.msfjarvis.aps.ui.adapters.FieldItemAdapter -import dev.msfjarvis.aps.util.extensions.unsafeLazy -import dev.msfjarvis.aps.util.extensions.viewBinding -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import java.io.ByteArrayOutputStream -import java.io.File -import javax.inject.Inject -import kotlin.time.Duration -import kotlin.time.ExperimentalTime -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat -import me.msfjarvis.openpgpktx.util.OpenPgpApi -import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection -import org.openintents.openpgp.IOpenPgpService2 - -@AndroidEntryPoint -class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { - - private val binding by viewBinding(DecryptLayoutBinding::inflate) - @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory - - private val relativeParentPath by unsafeLazy { getParentPath(fullPath, repoPath) } - private var passwordEntry: PasswordEntry? = null - - private val userInteractionRequiredResult = - registerForActivityResult(StartIntentSenderForResult()) { result -> - if (result.data == null) { - setResult(RESULT_CANCELED, null) - finish() - return@registerForActivityResult - } - - when (result.resultCode) { - RESULT_OK -> decryptAndVerify(result.data) - RESULT_CANCELED -> { - setResult(RESULT_CANCELED, result.data) - finish() - } - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - bindToOpenKeychain(this) - title = name - with(binding) { - setContentView(root) - passwordCategory.text = relativeParentPath - passwordFile.text = name - passwordFile.setOnLongClickListener { - copyTextToClipboard(name) - true - } - } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.pgp_handler, menu) - passwordEntry?.let { entry -> - menu.findItem(R.id.edit_password).isVisible = true - if (!entry.password.isNullOrBlank()) { - menu.findItem(R.id.share_password_as_plaintext).isVisible = true - menu.findItem(R.id.copy_password).isVisible = true - } - } - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> onBackPressed() - R.id.edit_password -> editPassword() - R.id.share_password_as_plaintext -> shareAsPlaintext() - R.id.copy_password -> copyPasswordToClipboard(passwordEntry?.password) - else -> return super.onOptionsItemSelected(item) - } - return true - } - - override fun onBound(service: IOpenPgpService2) { - super.onBound(service) - decryptAndVerify() - } - - override fun onError(e: Exception) { - logcat(ERROR) { e.asLog() } - } - - /** - * Automatically finishes the activity 60 seconds after decryption succeeded to prevent - * information leaks from stale activities. - */ - @OptIn(ExperimentalTime::class) - private fun startAutoDismissTimer() { - lifecycleScope.launch { - delay(Duration.seconds(60)) - finish() - } - } - - /** - * Edit the current password and hide all the fields populated by encrypted data so that when the - * result triggers they can be repopulated with new data. - */ - private fun editPassword() { - val intent = Intent(this, PasswordCreationActivity::class.java) - intent.putExtra("FILE_PATH", relativeParentPath) - intent.putExtra("REPO_PATH", repoPath) - intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name) - intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password) - intent.putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, passwordEntry?.extraContentString) - intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true) - startActivity(intent) - finish() - } - - private fun shareAsPlaintext() { - val sendIntent = - Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, passwordEntry?.password) - type = "text/plain" - } - // Always show a picker to give the user a chance to cancel - startActivity( - Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to)) - ) - } - - @OptIn(ExperimentalTime::class) - private fun decryptAndVerify(receivedIntent: Intent? = null) { - if (api == null) { - bindToOpenKeychain(this) - return - } - val data = receivedIntent ?: Intent() - data.action = OpenPgpApi.ACTION_DECRYPT_VERIFY - - val inputStream = File(fullPath).inputStream() - val outputStream = ByteArrayOutputStream() - - lifecycleScope.launch(Dispatchers.Main) { - val result = - withContext(Dispatchers.IO) { - checkNotNull(api).executeApi(data, inputStream, outputStream) - } - when (result.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { - OpenPgpApi.RESULT_CODE_SUCCESS -> { - startAutoDismissTimer() - runCatching { - val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true) - val entry = passwordEntryFactory.create(outputStream.toByteArray()) - - if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) { - copyPasswordToClipboard(entry.password) - } - - passwordEntry = entry - invalidateOptionsMenu() - - val items = arrayListOf() - if (!entry.password.isNullOrBlank()) { - items.add(FieldItem.createPasswordField(entry.password!!)) - } - - if (entry.hasTotp()) { - items.add(FieldItem.createOtpField(entry.totp.first())) - } - - if (!entry.username.isNullOrBlank()) { - items.add(FieldItem.createUsernameField(entry.username!!)) - } - - entry.extraContent.forEach { (key, value) -> - items.add(FieldItem(key, value, FieldItem.ActionType.COPY)) - } - - val adapter = - FieldItemAdapter(items, showPassword) { text -> copyTextToClipboard(text) } - binding.recyclerView.adapter = adapter - binding.recyclerView.itemAnimator = null - - if (entry.hasTotp()) { - entry.totp.onEach(adapter::updateOTPCode).launchIn(lifecycleScope) - } - } - .onFailure { e -> logcat(ERROR) { e.asLog() } } - } - OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { - val sender = getUserInteractionRequestIntent(result) - userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build()) - } - OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) - } - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivityV2.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivityV2.kt deleted file mode 100644 index 424d5c46..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivityV2.kt +++ /dev/null @@ -1,209 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.crypto - -import android.content.Intent -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import androidx.lifecycle.lifecycleScope -import com.github.michaelbull.result.runCatching -import dagger.hilt.android.AndroidEntryPoint -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.crypto.CryptoRepository -import dev.msfjarvis.aps.data.passfile.PasswordEntry -import dev.msfjarvis.aps.data.password.FieldItem -import dev.msfjarvis.aps.databinding.DecryptLayoutBinding -import dev.msfjarvis.aps.ui.adapters.FieldItemAdapter -import dev.msfjarvis.aps.util.extensions.isErr -import dev.msfjarvis.aps.util.extensions.unsafeLazy -import dev.msfjarvis.aps.util.extensions.viewBinding -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import java.io.ByteArrayOutputStream -import java.io.File -import javax.inject.Inject -import kotlin.time.Duration.Companion.seconds -import kotlin.time.ExperimentalTime -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@OptIn(ExperimentalTime::class) -@AndroidEntryPoint -class DecryptActivityV2 : BasePgpActivity() { - - private val binding by viewBinding(DecryptLayoutBinding::inflate) - @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory - @Inject lateinit var repository: CryptoRepository - private val relativeParentPath by unsafeLazy { getParentPath(fullPath, repoPath) } - - private var passwordEntry: PasswordEntry? = null - private var retries = 0 - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - title = name - with(binding) { - setContentView(root) - passwordCategory.text = relativeParentPath - passwordFile.text = name - passwordFile.setOnLongClickListener { - copyTextToClipboard(name) - true - } - } - decrypt(isError = false) - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.pgp_handler, menu) - passwordEntry?.let { entry -> - menu.findItem(R.id.edit_password).isVisible = true - if (!entry.password.isNullOrBlank()) { - menu.findItem(R.id.share_password_as_plaintext).isVisible = true - menu.findItem(R.id.copy_password).isVisible = true - } - } - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> onBackPressed() - R.id.edit_password -> editPassword() - R.id.share_password_as_plaintext -> shareAsPlaintext() - R.id.copy_password -> copyPasswordToClipboard(passwordEntry?.password) - else -> return super.onOptionsItemSelected(item) - } - return true - } - - /** - * Automatically finishes the activity 60 seconds after decryption succeeded to prevent - * information leaks from stale activities. - */ - private fun startAutoDismissTimer() { - lifecycleScope.launch { - delay(60.seconds) - finish() - } - } - - /** - * Edit the current password and hide all the fields populated by encrypted data so that when the - * result triggers they can be repopulated with new data. - */ - private fun editPassword() { - val intent = Intent(this, PasswordCreationActivityV2::class.java) - intent.putExtra("FILE_PATH", relativeParentPath) - intent.putExtra("REPO_PATH", repoPath) - intent.putExtra(PasswordCreationActivityV2.EXTRA_FILE_NAME, name) - intent.putExtra(PasswordCreationActivityV2.EXTRA_PASSWORD, passwordEntry?.password) - intent.putExtra( - PasswordCreationActivityV2.EXTRA_EXTRA_CONTENT, - passwordEntry?.extraContentString - ) - intent.putExtra(PasswordCreationActivityV2.EXTRA_EDITING, true) - startActivity(intent) - finish() - } - - private fun shareAsPlaintext() { - val sendIntent = - Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, passwordEntry?.password) - type = "text/plain" - } - // Always show a picker to give the user a chance to cancel - startActivity( - Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to)) - ) - } - - private fun decrypt(isError: Boolean) { - if (retries < MAX_RETRIES) { - retries += 1 - } else { - finish() - } - val dialog = PasswordDialog() - if (isError) { - dialog.setError() - } - lifecycleScope.launch(Dispatchers.Main) { - dialog.password.collectLatest { value -> - if (value != null) { - if (runCatching { decrypt(value) }.isErr()) { - decrypt(isError = true) - } - } - } - } - dialog.show(supportFragmentManager, "PASSWORD_DIALOG") - } - - private suspend fun decrypt(password: String) { - val message = withContext(Dispatchers.IO) { File(fullPath).readBytes().inputStream() } - val result = - withContext(Dispatchers.IO) { - val outputStream = ByteArrayOutputStream() - repository.decrypt( - password, - message, - outputStream, - ) - outputStream - } - require(result.size() != 0) { "Incorrect password" } - startAutoDismissTimer() - - val entry = passwordEntryFactory.create(result.toByteArray()) - passwordEntry = entry - createPasswordUi(entry) - } - - private suspend fun createPasswordUi(entry: PasswordEntry) = - withContext(Dispatchers.Main) { - val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true) - invalidateOptionsMenu() - - val items = arrayListOf() - if (!entry.password.isNullOrBlank()) { - items.add(FieldItem.createPasswordField(entry.password!!)) - } - - if (entry.hasTotp()) { - items.add(FieldItem.createOtpField(entry.totp.first())) - } - - if (!entry.username.isNullOrBlank()) { - items.add(FieldItem.createUsernameField(entry.username!!)) - } - - entry.extraContent.forEach { (key, value) -> - items.add(FieldItem(key, value, FieldItem.ActionType.COPY)) - } - - val adapter = FieldItemAdapter(items, showPassword) { text -> copyTextToClipboard(text) } - binding.recyclerView.adapter = adapter - binding.recyclerView.itemAnimator = null - - if (entry.hasTotp()) { - entry.totp.onEach(adapter::updateOTPCode).launchIn(lifecycleScope) - } - } - - private companion object { - private const val MAX_RETRIES = 3 - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt deleted file mode 100644 index afd30270..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.crypto - -import android.content.Intent -import android.os.Bundle -import androidx.activity.result.IntentSenderRequest -import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult -import androidx.lifecycle.lifecycleScope -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat -import me.msfjarvis.openpgpktx.util.OpenPgpApi -import me.msfjarvis.openpgpktx.util.OpenPgpUtils -import org.openintents.openpgp.IOpenPgpService2 - -class GetKeyIdsActivity : BasePgpActivity() { - - private val userInteractionRequiredResult = - registerForActivityResult(StartIntentSenderForResult()) { result -> - if (result.data == null || result.resultCode == RESULT_CANCELED) { - setResult(RESULT_CANCELED, result.data) - finish() - return@registerForActivityResult - } - getKeyIds(result.data!!) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - bindToOpenKeychain(this) - } - - override fun onBound(service: IOpenPgpService2) { - super.onBound(service) - getKeyIds() - } - - override fun onError(e: Exception) { - logcat(ERROR) { e.asLog() } - } - - /** Get the Key ids from OpenKeychain */ - private fun getKeyIds(data: Intent = Intent()) { - data.action = OpenPgpApi.ACTION_GET_KEY_IDS - lifecycleScope.launch(Dispatchers.Main) { - val result = withContext(Dispatchers.IO) { checkNotNull(api).executeApi(data, null, null) } - when (result.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { - OpenPgpApi.RESULT_CODE_SUCCESS -> { - runCatching { - val ids = - result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS)?.map { - OpenPgpUtils.convertKeyIdToHex(it) - } - ?: emptyList() - val keyResult = Intent().putExtra(OpenPgpApi.EXTRA_KEY_IDS, ids.toTypedArray()) - setResult(RESULT_OK, keyResult) - finish() - } - .onFailure { e -> logcat(ERROR) { e.asLog() } } - } - OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { - val sender = getUserInteractionRequestIntent(result) - userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build()) - } - OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) - } - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt deleted file mode 100644 index b1f11dfa..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt +++ /dev/null @@ -1,617 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.crypto - -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.graphics.Bitmap -import android.graphics.ImageDecoder -import android.os.Build -import android.os.Bundle -import android.provider.MediaStore -import android.text.InputType -import android.view.Menu -import android.view.MenuItem -import android.view.View -import androidx.activity.result.IntentSenderRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult -import androidx.core.content.edit -import androidx.core.view.isVisible -import androidx.core.widget.doAfterTextChanged -import androidx.lifecycle.lifecycleScope -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.onSuccess -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import com.google.zxing.BinaryBitmap -import com.google.zxing.LuminanceSource -import com.google.zxing.RGBLuminanceSource -import com.google.zxing.common.HybridBinarizer -import com.google.zxing.integration.android.IntentIntegrator -import com.google.zxing.integration.android.IntentIntegrator.QR_CODE -import com.google.zxing.qrcode.QRCodeReader -import dagger.hilt.android.AndroidEntryPoint -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.passfile.PasswordEntry -import dev.msfjarvis.aps.data.repo.PasswordRepository -import dev.msfjarvis.aps.databinding.PasswordCreationActivityBinding -import dev.msfjarvis.aps.ui.dialogs.DicewarePasswordGeneratorDialogFragment -import dev.msfjarvis.aps.ui.dialogs.OtpImportDialogFragment -import dev.msfjarvis.aps.ui.dialogs.PasswordGeneratorDialogFragment -import dev.msfjarvis.aps.util.autofill.AutofillPreferences -import dev.msfjarvis.aps.util.autofill.DirectoryStructure -import dev.msfjarvis.aps.util.crypto.GpgIdentifier -import dev.msfjarvis.aps.util.extensions.asLog -import dev.msfjarvis.aps.util.extensions.base64 -import dev.msfjarvis.aps.util.extensions.commitChange -import dev.msfjarvis.aps.util.extensions.getString -import dev.msfjarvis.aps.util.extensions.isInsideRepository -import dev.msfjarvis.aps.util.extensions.snackbar -import dev.msfjarvis.aps.util.extensions.unsafeLazy -import dev.msfjarvis.aps.util.extensions.viewBinding -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.File -import java.io.IOException -import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat -import me.msfjarvis.openpgpktx.util.OpenPgpApi -import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection - -@AndroidEntryPoint -class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { - - private val binding by viewBinding(PasswordCreationActivityBinding::inflate) - @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory - - private val suggestedName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) } - private val suggestedPass by unsafeLazy { intent.getStringExtra(EXTRA_PASSWORD) } - private val suggestedExtra by unsafeLazy { intent.getStringExtra(EXTRA_EXTRA_CONTENT) } - private val shouldGeneratePassword by unsafeLazy { - intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) - } - private val editing by unsafeLazy { intent.getBooleanExtra(EXTRA_EDITING, false) } - private val oldFileName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) } - private var oldCategory: String? = null - private var copy: Boolean = false - private var encryptionIntent: Intent = Intent() - - private val userInteractionRequiredResult = - registerForActivityResult(StartIntentSenderForResult()) { result -> - if (result.data == null) { - setResult(RESULT_CANCELED, null) - finish() - return@registerForActivityResult - } - - when (result.resultCode) { - RESULT_OK -> encrypt(result.data) - RESULT_CANCELED -> { - setResult(RESULT_CANCELED, result.data) - finish() - } - } - } - - private val otpImportAction = - registerForActivityResult(StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - binding.otpImportButton.isVisible = false - val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data) - val contents = "${intentResult.contents}\n" - val currentExtras = binding.extraContent.text.toString() - if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') - binding.extraContent.append("\n$contents") - else binding.extraContent.append(contents) - snackbar(message = getString(R.string.otp_import_success)) - } else { - snackbar(message = getString(R.string.otp_import_failure)) - } - } - - private val imageImportAction = - registerForActivityResult(ActivityResultContracts.GetContent()) { imageUri -> - if (imageUri == null) { - snackbar(message = getString(R.string.otp_import_failure)) - return@registerForActivityResult - } - val bitmap = - if (Build.VERSION.SDK_INT >= 28) { - ImageDecoder.decodeBitmap(ImageDecoder.createSource(contentResolver, imageUri)) - .copy(Bitmap.Config.ARGB_8888, true) - } else { - @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(contentResolver, imageUri) - } - val intArray = IntArray(bitmap.width * bitmap.height) - // copy pixel data from the Bitmap into the 'intArray' array - bitmap.getPixels(intArray, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) - val source: LuminanceSource = RGBLuminanceSource(bitmap.width, bitmap.height, intArray) - val binaryBitmap = BinaryBitmap(HybridBinarizer(source)) - - val reader = QRCodeReader() - runCatching { - val result = reader.decode(binaryBitmap) - val text = result.text - val currentExtras = binding.extraContent.text.toString() - if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') - binding.extraContent.append("\n$text") - else binding.extraContent.append(text) - snackbar(message = getString(R.string.otp_import_success)) - binding.otpImportButton.isVisible = false - } - .onFailure { snackbar(message = getString(R.string.otp_import_failure)) } - } - - private val gpgKeySelectAction = - registerForActivityResult(StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds -> - lifecycleScope.launch { - val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id") - withContext(Dispatchers.IO) { - gpgIdentifierFile.writeText((keyIds + "").joinToString("\n")) - } - commitChange( - getString( - R.string.git_commit_gpg_id, - getLongName( - gpgIdentifierFile.parentFile!!.absolutePath, - repoPath, - gpgIdentifierFile.name - ) - ) - ) - .onSuccess { encrypt(encryptionIntent) } - } - } - } else { - snackbar( - message = getString(R.string.gpg_key_select_mandatory), - length = Snackbar.LENGTH_LONG - ) - } - } - - private fun File.findTillRoot(fileName: String, rootPath: File): File? { - val gpgFile = File(this, fileName) - if (gpgFile.exists()) return gpgFile - - if (this.absolutePath == rootPath.absolutePath) { - return null - } - - val parent = parentFile - return if (parent != null && parent.exists()) { - parent.findTillRoot(fileName, rootPath) - } else { - null - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - bindToOpenKeychain(this) - title = - if (editing) getString(R.string.edit_password) else getString(R.string.new_password_title) - with(binding) { - setContentView(root) - generatePassword.setOnClickListener { generatePassword() } - otpImportButton.setOnClickListener { - supportFragmentManager.setFragmentResultListener( - OTP_RESULT_REQUEST_KEY, - this@PasswordCreationActivity - ) { requestKey, bundle -> - if (requestKey == OTP_RESULT_REQUEST_KEY) { - val contents = bundle.getString(RESULT) - val currentExtras = binding.extraContent.text.toString() - if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') - binding.extraContent.append("\n$contents") - else binding.extraContent.append(contents) - } - } - val hasCamera = packageManager?.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) == true - if (hasCamera) { - val items = - arrayOf( - getString(R.string.otp_import_qr_code), - getString(R.string.otp_import_from_file), - getString(R.string.otp_import_manual_entry), - ) - MaterialAlertDialogBuilder(this@PasswordCreationActivity) - .setItems(items) { _, index -> - when (index) { - 0 -> - otpImportAction.launch( - IntentIntegrator(this@PasswordCreationActivity) - .setOrientationLocked(false) - .setBeepEnabled(false) - .setDesiredBarcodeFormats(QR_CODE) - .createScanIntent() - ) - 1 -> imageImportAction.launch("image/*") - 2 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") - } - } - .show() - } else { - OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") - } - } - - directoryInputLayout.apply { - if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) { - isEnabled = true - } else { - setBackgroundColor(getColor(android.R.color.transparent)) - } - val path = getRelativePath(fullPath, repoPath) - // Keep empty path field visible if it is editable. - if (path.isEmpty() && !isEnabled) visibility = View.GONE - else { - directory.setText(path) - oldCategory = path - } - } - if (suggestedName != null) { - filename.setText(suggestedName) - } else { - filename.requestFocus() - } - // Allow the user to quickly switch between storing the username as the filename or - // in the encrypted extras. This only makes sense if the directory structure is - // FileBased. - if ( - suggestedName == null && - AutofillPreferences.directoryStructure(this@PasswordCreationActivity) == - DirectoryStructure.FileBased - ) { - encryptUsername.apply { - visibility = View.VISIBLE - setOnClickListener { - if (isChecked) { - // User wants to enable username encryption, so we add it to the - // encrypted extras as the first line. - val username = filename.text.toString() - val extras = "username:$username\n${extraContent.text}" - - filename.text?.clear() - extraContent.setText(extras) - } else { - // User wants to disable username encryption, so we extract the - // username from the encrypted extras and use it as the filename. - val entry = - passwordEntryFactory.create("PASSWORD\n${extraContent.text}".encodeToByteArray()) - val username = entry.username - - // username should not be null here by the logic in - // updateViewState, but it could still happen due to - // input lag. - if (username != null) { - filename.setText(username) - extraContent.setText(entry.extraContentWithoutAuthData) - } - } - } - } - } - suggestedPass?.let { - password.setText(it) - password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - } - suggestedExtra?.let { extraContent.setText(it) } - if (shouldGeneratePassword) { - generatePassword() - password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - } - } - listOf(binding.filename, binding.extraContent).forEach { - it.doAfterTextChanged { updateViewState() } - } - updateViewState() - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.pgp_handler_new_password, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - setResult(RESULT_CANCELED) - onBackPressed() - } - R.id.save_password -> { - copy = false - encrypt() - } - R.id.save_and_copy_password -> { - copy = true - encrypt() - } - else -> return super.onOptionsItemSelected(item) - } - return true - } - - private fun generatePassword() { - supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) { - requestKey, - bundle -> - if (requestKey == PASSWORD_RESULT_REQUEST_KEY) { - binding.password.setText(bundle.getString(RESULT)) - } - } - when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_CLASSIC) { - KEY_PWGEN_TYPE_CLASSIC -> - PasswordGeneratorDialogFragment().show(supportFragmentManager, "generator") - KEY_PWGEN_TYPE_DICEWARE -> - DicewarePasswordGeneratorDialogFragment().show(supportFragmentManager, "generator") - } - } - - private fun updateViewState() = - with(binding) { - // Use PasswordEntry to parse extras for username - val entry = - passwordEntryFactory.create("PLACEHOLDER\n${extraContent.text}".encodeToByteArray()) - encryptUsername.apply { - if (visibility != View.VISIBLE) return@apply - val hasUsernameInFileName = filename.text.toString().isNotBlank() - val hasUsernameInExtras = !entry.username.isNullOrBlank() - isEnabled = hasUsernameInFileName xor hasUsernameInExtras - isChecked = hasUsernameInExtras - } - otpImportButton.isVisible = !entry.hasTotp() - } - - /** Encrypts the password and the extra content */ - private fun encrypt(receivedIntent: Intent? = null) { - with(binding) { - val editName = filename.text.toString().trim() - val editPass = password.text.toString() - val editExtra = extraContent.text.toString() - - if (editName.isEmpty()) { - snackbar(message = resources.getString(R.string.file_toast_text)) - return@with - } else if (editName.contains('/')) { - snackbar(message = resources.getString(R.string.invalid_filename_text)) - return@with - } - - if (editPass.isEmpty() && editExtra.isEmpty()) { - snackbar(message = resources.getString(R.string.empty_toast_text)) - return@with - } - - if (copy) { - copyPasswordToClipboard(editPass) - } - - encryptionIntent = receivedIntent ?: Intent() - encryptionIntent.action = OpenPgpApi.ACTION_ENCRYPT - - // pass enters the key ID into `.gpg-id`. - val repoRoot = PasswordRepository.getRepositoryDirectory() - val gpgIdentifierFile = - File(repoRoot, directory.text.toString()).findTillRoot(".gpg-id", repoRoot) - ?: File(repoRoot, ".gpg-id").apply { createNewFile() } - val gpgIdentifiers = - gpgIdentifierFile - .readLines() - .filter { it.isNotBlank() } - .map { line -> - GpgIdentifier.fromString(line) - ?: run { - // The line being empty means this is most likely an empty `.gpg-id` - // file we created. Skip the validation so we can make the user add a - // real ID. - if (line.isEmpty()) return@run - if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) { - snackbar(message = resources.getString(R.string.short_key_ids_unsupported)) - } else { - snackbar(message = resources.getString(R.string.invalid_gpg_id)) - } - return@with - } - } - if (gpgIdentifiers.isEmpty()) { - gpgKeySelectAction.launch( - Intent(this@PasswordCreationActivity, GetKeyIdsActivity::class.java) - ) - return@with - } - val keyIds = - gpgIdentifiers.filterIsInstance().map { it.id }.toLongArray() - if (keyIds.isNotEmpty()) { - encryptionIntent.putExtra(OpenPgpApi.EXTRA_KEY_IDS, keyIds) - } - val userIds = - gpgIdentifiers.filterIsInstance().map { it.email }.toTypedArray() - if (userIds.isNotEmpty()) { - encryptionIntent.putExtra(OpenPgpApi.EXTRA_USER_IDS, userIds) - } - - encryptionIntent.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, false) - - val content = "$editPass\n$editExtra" - val inputStream = ByteArrayInputStream(content.toByteArray()) - val outputStream = ByteArrayOutputStream() - - val path = - when { - // If we allowed the user to edit the relative path, we have to consider it here - // instead - // of fullPath. - directoryInputLayout.isEnabled -> { - val editRelativePath = directory.text.toString().trim() - if (editRelativePath.isEmpty()) { - snackbar(message = resources.getString(R.string.path_toast_text)) - return - } - val passwordDirectory = File("$repoPath/${editRelativePath.trim('/')}") - if (!passwordDirectory.exists() && !passwordDirectory.mkdir()) { - snackbar(message = "Failed to create directory ${editRelativePath.trim('/')}") - return - } - - "${passwordDirectory.path}/$editName.gpg" - } - else -> "$fullPath/$editName.gpg" - } - - lifecycleScope.launch(Dispatchers.Main) { - val result = - withContext(Dispatchers.IO) { - checkNotNull(api).executeApi(encryptionIntent, inputStream, outputStream) - } - when (result.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { - OpenPgpApi.RESULT_CODE_SUCCESS -> { - runCatching { - val file = File(path) - // If we're not editing, this file should not already exist! - // Additionally, if we were editing and the incoming and outgoing - // filenames differ, it means we renamed. Ensure that the target - // doesn't already exist to prevent an accidental overwrite. - if ( - (!editing || (editing && suggestedName != file.nameWithoutExtension)) && - file.exists() - ) { - snackbar(message = getString(R.string.password_creation_duplicate_error)) - return@runCatching - } - - if (!file.isInsideRepository()) { - snackbar(message = getString(R.string.message_error_destination_outside_repo)) - return@runCatching - } - - withContext(Dispatchers.IO) { - file.outputStream().use { it.write(outputStream.toByteArray()) } - } - - // associate the new password name with the last name's timestamp in - // history - val preference = - getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) - val oldFilePathHash = - "$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg".base64() - val timestamp = preference.getString(oldFilePathHash) - if (timestamp != null) { - preference.edit { - remove(oldFilePathHash) - putString(file.absolutePath.base64(), timestamp) - } - } - - val returnIntent = Intent() - returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path) - returnIntent.putExtra(RETURN_EXTRA_NAME, editName) - returnIntent.putExtra( - RETURN_EXTRA_LONG_NAME, - getLongName(fullPath, repoPath, editName) - ) - - if (shouldGeneratePassword) { - val directoryStructure = - AutofillPreferences.directoryStructure(applicationContext) - val entry = passwordEntryFactory.create(content.encodeToByteArray()) - returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password) - val username = entry.username ?: directoryStructure.getUsernameFor(file) - returnIntent.putExtra(RETURN_EXTRA_USERNAME, username) - } - - if ( - directoryInputLayout.isVisible && - directoryInputLayout.isEnabled && - oldFileName != null - ) { - val oldFile = File("$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg") - if (oldFile.path != file.path && !oldFile.delete()) { - setResult(RESULT_CANCELED) - MaterialAlertDialogBuilder(this@PasswordCreationActivity) - .setTitle(R.string.password_creation_file_fail_title) - .setMessage( - getString(R.string.password_creation_file_delete_fail_message, oldFileName) - ) - .setCancelable(false) - .setPositiveButton(android.R.string.ok) { _, _ -> finish() } - .show() - return@runCatching - } - } - - val commitMessageRes = - if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text - lifecycleScope.launch { - commitChange( - resources.getString( - commitMessageRes, - getLongName(fullPath, repoPath, editName) - ) - ) - .onSuccess { - setResult(RESULT_OK, returnIntent) - finish() - } - } - } - .onFailure { e -> - if (e is IOException) { - logcat(ERROR) { e.asLog("Failed to write password file") } - setResult(RESULT_CANCELED) - MaterialAlertDialogBuilder(this@PasswordCreationActivity) - .setTitle(getString(R.string.password_creation_file_fail_title)) - .setMessage(getString(R.string.password_creation_file_write_fail_message)) - .setCancelable(false) - .setPositiveButton(android.R.string.ok) { _, _ -> finish() } - .show() - } else { - logcat(ERROR) { e.asLog() } - } - } - } - OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { - val sender = getUserInteractionRequestIntent(result) - userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build()) - } - OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) - } - } - } - } - - companion object { - - private const val KEY_PWGEN_TYPE_CLASSIC = "classic" - private const val KEY_PWGEN_TYPE_DICEWARE = "diceware" - const val PASSWORD_RESULT_REQUEST_KEY = "PASSWORD_GENERATOR" - const val OTP_RESULT_REQUEST_KEY = "OTP_IMPORT" - const val RESULT = "RESULT" - const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE" - const val RETURN_EXTRA_NAME = "NAME" - const val RETURN_EXTRA_LONG_NAME = "LONG_NAME" - const val RETURN_EXTRA_USERNAME = "USERNAME" - const val RETURN_EXTRA_PASSWORD = "PASSWORD" - const val EXTRA_FILE_NAME = "FILENAME" - const val EXTRA_PASSWORD = "PASSWORD" - const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT" - const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD" - const val EXTRA_EDITING = "EDITING" - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivityV2.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivityV2.kt deleted file mode 100644 index 5792c892..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivityV2.kt +++ /dev/null @@ -1,479 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.crypto - -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.graphics.Bitmap -import android.graphics.ImageDecoder -import android.os.Build -import android.os.Bundle -import android.provider.MediaStore -import android.text.InputType -import android.view.Menu -import android.view.MenuItem -import android.view.View -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.core.content.edit -import androidx.core.view.isVisible -import androidx.core.widget.doAfterTextChanged -import androidx.lifecycle.lifecycleScope -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.onSuccess -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.zxing.BinaryBitmap -import com.google.zxing.LuminanceSource -import com.google.zxing.RGBLuminanceSource -import com.google.zxing.common.HybridBinarizer -import com.google.zxing.integration.android.IntentIntegrator -import com.google.zxing.integration.android.IntentIntegrator.QR_CODE -import com.google.zxing.qrcode.QRCodeReader -import dagger.hilt.android.AndroidEntryPoint -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.crypto.CryptoRepository -import dev.msfjarvis.aps.data.passfile.PasswordEntry -import dev.msfjarvis.aps.databinding.PasswordCreationActivityBinding -import dev.msfjarvis.aps.ui.dialogs.DicewarePasswordGeneratorDialogFragment -import dev.msfjarvis.aps.ui.dialogs.OtpImportDialogFragment -import dev.msfjarvis.aps.ui.dialogs.PasswordGeneratorDialogFragment -import dev.msfjarvis.aps.util.autofill.AutofillPreferences -import dev.msfjarvis.aps.util.autofill.DirectoryStructure -import dev.msfjarvis.aps.util.extensions.asLog -import dev.msfjarvis.aps.util.extensions.base64 -import dev.msfjarvis.aps.util.extensions.commitChange -import dev.msfjarvis.aps.util.extensions.getString -import dev.msfjarvis.aps.util.extensions.isInsideRepository -import dev.msfjarvis.aps.util.extensions.snackbar -import dev.msfjarvis.aps.util.extensions.unsafeLazy -import dev.msfjarvis.aps.util.extensions.viewBinding -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import java.io.ByteArrayOutputStream -import java.io.File -import java.io.IOException -import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat - -@AndroidEntryPoint -class PasswordCreationActivityV2 : BasePgpActivity() { - - private val binding by viewBinding(PasswordCreationActivityBinding::inflate) - @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory - @Inject lateinit var repository: CryptoRepository - - private val suggestedName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) } - private val suggestedPass by unsafeLazy { intent.getStringExtra(EXTRA_PASSWORD) } - private val suggestedExtra by unsafeLazy { intent.getStringExtra(EXTRA_EXTRA_CONTENT) } - private val shouldGeneratePassword by unsafeLazy { - intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) - } - private val editing by unsafeLazy { intent.getBooleanExtra(EXTRA_EDITING, false) } - private val oldFileName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) } - private var oldCategory: String? = null - private var copy: Boolean = false - - private val otpImportAction = - registerForActivityResult(StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - binding.otpImportButton.isVisible = false - val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data) - val contents = "${intentResult.contents}\n" - val currentExtras = binding.extraContent.text.toString() - if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') - binding.extraContent.append("\n$contents") - else binding.extraContent.append(contents) - snackbar(message = getString(R.string.otp_import_success)) - } else { - snackbar(message = getString(R.string.otp_import_failure)) - } - } - - private val imageImportAction = - registerForActivityResult(ActivityResultContracts.GetContent()) { imageUri -> - if (imageUri == null) { - snackbar(message = getString(R.string.otp_import_failure)) - return@registerForActivityResult - } - val bitmap = - if (Build.VERSION.SDK_INT >= 28) { - ImageDecoder.decodeBitmap(ImageDecoder.createSource(contentResolver, imageUri)) - .copy(Bitmap.Config.ARGB_8888, true) - } else { - @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(contentResolver, imageUri) - } - val intArray = IntArray(bitmap.width * bitmap.height) - // copy pixel data from the Bitmap into the 'intArray' array - bitmap.getPixels(intArray, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) - val source: LuminanceSource = RGBLuminanceSource(bitmap.width, bitmap.height, intArray) - val binaryBitmap = BinaryBitmap(HybridBinarizer(source)) - - val reader = QRCodeReader() - runCatching { - val result = reader.decode(binaryBitmap) - val text = result.text - val currentExtras = binding.extraContent.text.toString() - if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') - binding.extraContent.append("\n$text") - else binding.extraContent.append(text) - snackbar(message = getString(R.string.otp_import_success)) - binding.otpImportButton.isVisible = false - } - .onFailure { snackbar(message = getString(R.string.otp_import_failure)) } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - bindToOpenKeychain(this) - title = - if (editing) getString(R.string.edit_password) else getString(R.string.new_password_title) - with(binding) { - setContentView(root) - generatePassword.setOnClickListener { generatePassword() } - otpImportButton.setOnClickListener { - supportFragmentManager.setFragmentResultListener( - OTP_RESULT_REQUEST_KEY, - this@PasswordCreationActivityV2 - ) { requestKey, bundle -> - if (requestKey == OTP_RESULT_REQUEST_KEY) { - val contents = bundle.getString(RESULT) - val currentExtras = binding.extraContent.text.toString() - if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') - binding.extraContent.append("\n$contents") - else binding.extraContent.append(contents) - } - } - val hasCamera = packageManager?.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) == true - if (hasCamera) { - val items = - arrayOf( - getString(R.string.otp_import_qr_code), - getString(R.string.otp_import_from_file), - getString(R.string.otp_import_manual_entry), - ) - MaterialAlertDialogBuilder(this@PasswordCreationActivityV2) - .setItems(items) { _, index -> - when (index) { - 0 -> - otpImportAction.launch( - IntentIntegrator(this@PasswordCreationActivityV2) - .setOrientationLocked(false) - .setBeepEnabled(false) - .setDesiredBarcodeFormats(QR_CODE) - .createScanIntent() - ) - 1 -> imageImportAction.launch("image/*") - 2 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") - } - } - .show() - } else { - OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") - } - } - - directoryInputLayout.apply { - if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) { - isEnabled = true - } else { - setBackgroundColor(getColor(android.R.color.transparent)) - } - val path = getRelativePath(fullPath, repoPath) - // Keep empty path field visible if it is editable. - if (path.isEmpty() && !isEnabled) visibility = View.GONE - else { - directory.setText(path) - oldCategory = path - } - } - if (suggestedName != null) { - filename.setText(suggestedName) - } else { - filename.requestFocus() - } - // Allow the user to quickly switch between storing the username as the filename or - // in the encrypted extras. This only makes sense if the directory structure is - // FileBased. - if ( - suggestedName == null && - AutofillPreferences.directoryStructure(this@PasswordCreationActivityV2) == - DirectoryStructure.FileBased - ) { - encryptUsername.apply { - visibility = View.VISIBLE - setOnClickListener { - if (isChecked) { - // User wants to enable username encryption, so we add it to the - // encrypted extras as the first line. - val username = filename.text.toString() - val extras = "username:$username\n${extraContent.text}" - - filename.text?.clear() - extraContent.setText(extras) - } else { - // User wants to disable username encryption, so we extract the - // username from the encrypted extras and use it as the filename. - val entry = - passwordEntryFactory.create("PASSWORD\n${extraContent.text}".encodeToByteArray()) - val username = entry.username - - // username should not be null here by the logic in - // updateViewState, but it could still happen due to - // input lag. - if (username != null) { - filename.setText(username) - extraContent.setText(entry.extraContentWithoutAuthData) - } - } - } - } - } - suggestedPass?.let { - password.setText(it) - password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - } - suggestedExtra?.let { extraContent.setText(it) } - if (shouldGeneratePassword) { - generatePassword() - password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - } - } - listOf(binding.filename, binding.extraContent).forEach { - it.doAfterTextChanged { updateViewState() } - } - updateViewState() - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.pgp_handler_new_password, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - setResult(RESULT_CANCELED) - onBackPressed() - } - R.id.save_password -> { - copy = false - encrypt() - } - R.id.save_and_copy_password -> { - copy = true - encrypt() - } - else -> return super.onOptionsItemSelected(item) - } - return true - } - - private fun generatePassword() { - supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) { - requestKey, - bundle -> - if (requestKey == PASSWORD_RESULT_REQUEST_KEY) { - binding.password.setText(bundle.getString(RESULT)) - } - } - when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_CLASSIC) { - KEY_PWGEN_TYPE_CLASSIC -> - PasswordGeneratorDialogFragment().show(supportFragmentManager, "generator") - KEY_PWGEN_TYPE_DICEWARE -> - DicewarePasswordGeneratorDialogFragment().show(supportFragmentManager, "generator") - } - } - - private fun updateViewState() = - with(binding) { - // Use PasswordEntry to parse extras for username - val entry = - passwordEntryFactory.create("PLACEHOLDER\n${extraContent.text}".encodeToByteArray()) - encryptUsername.apply { - if (visibility != View.VISIBLE) return@apply - val hasUsernameInFileName = filename.text.toString().isNotBlank() - val hasUsernameInExtras = !entry.username.isNullOrBlank() - isEnabled = hasUsernameInFileName xor hasUsernameInExtras - isChecked = hasUsernameInExtras - } - otpImportButton.isVisible = !entry.hasTotp() - } - - /** Encrypts the password and the extra content */ - private fun encrypt() { - with(binding) { - val editName = filename.text.toString().trim() - val editPass = password.text.toString() - val editExtra = extraContent.text.toString() - - if (editName.isEmpty()) { - snackbar(message = resources.getString(R.string.file_toast_text)) - return@with - } else if (editName.contains('/')) { - snackbar(message = resources.getString(R.string.invalid_filename_text)) - return@with - } - - if (editPass.isEmpty() && editExtra.isEmpty()) { - snackbar(message = resources.getString(R.string.empty_toast_text)) - return@with - } - - if (copy) { - copyPasswordToClipboard(editPass) - } - - val content = "$editPass\n$editExtra" - val path = - when { - // If we allowed the user to edit the relative path, we have to consider it here - // instead - // of fullPath. - directoryInputLayout.isEnabled -> { - val editRelativePath = directory.text.toString().trim() - if (editRelativePath.isEmpty()) { - snackbar(message = resources.getString(R.string.path_toast_text)) - return - } - val passwordDirectory = File("$repoPath/${editRelativePath.trim('/')}") - if (!passwordDirectory.exists() && !passwordDirectory.mkdir()) { - snackbar(message = "Failed to create directory ${editRelativePath.trim('/')}") - return - } - - "${passwordDirectory.path}/$editName.gpg" - } - else -> "$fullPath/$editName.gpg" - } - - lifecycleScope.launch(Dispatchers.Main) { - runCatching { - val result = - withContext(Dispatchers.IO) { - val outputStream = ByteArrayOutputStream() - repository.encrypt(content.byteInputStream(), outputStream) - outputStream - } - val file = File(path) - // If we're not editing, this file should not already exist! - // Additionally, if we were editing and the incoming and outgoing - // filenames differ, it means we renamed. Ensure that the target - // doesn't already exist to prevent an accidental overwrite. - if ( - (!editing || (editing && suggestedName != file.nameWithoutExtension)) && file.exists() - ) { - snackbar(message = getString(R.string.password_creation_duplicate_error)) - return@runCatching - } - - if (!file.isInsideRepository()) { - snackbar(message = getString(R.string.message_error_destination_outside_repo)) - return@runCatching - } - - withContext(Dispatchers.IO) { file.writeBytes(result.toByteArray()) } - - // associate the new password name with the last name's timestamp in - // history - val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) - val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg".base64() - val timestamp = preference.getString(oldFilePathHash) - if (timestamp != null) { - preference.edit { - remove(oldFilePathHash) - putString(file.absolutePath.base64(), timestamp) - } - } - - val returnIntent = Intent() - returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path) - returnIntent.putExtra(RETURN_EXTRA_NAME, editName) - returnIntent.putExtra(RETURN_EXTRA_LONG_NAME, getLongName(fullPath, repoPath, editName)) - - if (shouldGeneratePassword) { - val directoryStructure = AutofillPreferences.directoryStructure(applicationContext) - val entry = passwordEntryFactory.create(content.encodeToByteArray()) - returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password) - val username = entry.username ?: directoryStructure.getUsernameFor(file) - returnIntent.putExtra(RETURN_EXTRA_USERNAME, username) - } - - if ( - directoryInputLayout.isVisible && - directoryInputLayout.isEnabled && - oldFileName != null - ) { - val oldFile = File("$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg") - if (oldFile.path != file.path && !oldFile.delete()) { - setResult(RESULT_CANCELED) - MaterialAlertDialogBuilder(this@PasswordCreationActivityV2) - .setTitle(R.string.password_creation_file_fail_title) - .setMessage( - getString(R.string.password_creation_file_delete_fail_message, oldFileName) - ) - .setCancelable(false) - .setPositiveButton(android.R.string.ok) { _, _ -> finish() } - .show() - return@runCatching - } - } - - val commitMessageRes = - if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text - lifecycleScope.launch { - commitChange( - resources.getString(commitMessageRes, getLongName(fullPath, repoPath, editName)) - ) - .onSuccess { - setResult(RESULT_OK, returnIntent) - finish() - } - } - } - .onFailure { e -> - if (e is IOException) { - logcat(ERROR) { e.asLog("Failed to write password file") } - setResult(RESULT_CANCELED) - MaterialAlertDialogBuilder(this@PasswordCreationActivityV2) - .setTitle(getString(R.string.password_creation_file_fail_title)) - .setMessage(getString(R.string.password_creation_file_write_fail_message)) - .setCancelable(false) - .setPositiveButton(android.R.string.ok) { _, _ -> finish() } - .show() - } else { - logcat(ERROR) { e.asLog() } - } - } - } - } - } - - companion object { - - private const val KEY_PWGEN_TYPE_CLASSIC = "classic" - private const val KEY_PWGEN_TYPE_DICEWARE = "diceware" - const val PASSWORD_RESULT_REQUEST_KEY = "PASSWORD_GENERATOR" - const val OTP_RESULT_REQUEST_KEY = "OTP_IMPORT" - const val RESULT = "RESULT" - const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE" - const val RETURN_EXTRA_NAME = "NAME" - const val RETURN_EXTRA_LONG_NAME = "LONG_NAME" - const val RETURN_EXTRA_USERNAME = "USERNAME" - const val RETURN_EXTRA_PASSWORD = "PASSWORD" - const val EXTRA_FILE_NAME = "FILENAME" - const val EXTRA_PASSWORD = "PASSWORD" - const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT" - const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD" - const val EXTRA_EDITING = "EDITING" - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordDialog.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordDialog.kt deleted file mode 100644 index d69a4686..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordDialog.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright © 2014-2022 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.crypto - -import android.app.Dialog -import android.content.DialogInterface -import android.os.Bundle -import android.view.KeyEvent -import android.view.WindowManager -import androidx.core.widget.doOnTextChanged -import androidx.fragment.app.DialogFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.databinding.DialogPasswordEntryBinding -import dev.msfjarvis.aps.util.extensions.finish -import dev.msfjarvis.aps.util.extensions.unsafeLazy -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow - -/** [DialogFragment] to request a password from the user and forward it along. */ -class PasswordDialog : DialogFragment() { - - private val binding by unsafeLazy { DialogPasswordEntryBinding.inflate(layoutInflater) } - private var isError: Boolean = false - private val _password = MutableStateFlow(null) - val password = _password.asStateFlow() - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = MaterialAlertDialogBuilder(requireContext()) - builder.setView(binding.root) - builder.setTitle(R.string.password) - builder.setPositiveButton(android.R.string.ok) { _, _ -> tryEmitPassword() } - val dialog = builder.create() - dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - dialog.setOnShowListener { - if (isError) { - binding.passwordField.error = getString(R.string.git_operation_wrong_password) - } - binding.passwordEditText.doOnTextChanged { _, _, _, _ -> binding.passwordField.error = null } - binding.passwordEditText.setOnKeyListener { _, keyCode, _ -> - if (keyCode == KeyEvent.KEYCODE_ENTER) { - tryEmitPassword() - return@setOnKeyListener true - } - false - } - } - return dialog - } - - fun setError() { - isError = true - } - - override fun onCancel(dialog: DialogInterface) { - super.onCancel(dialog) - finish() - } - - @Suppress("ControlFlowWithEmptyBody") - private fun tryEmitPassword() { - do {} while (!_password.tryEmit(binding.passwordEditText.text.toString())) - dismissAllowingStateLoss() - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt deleted file mode 100644 index 53d9a201..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt +++ /dev/null @@ -1,166 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.dialogs - -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewTreeObserver -import android.widget.FrameLayout -import androidx.annotation.StringRes -import androidx.core.view.isVisible -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.databinding.BasicBottomSheetBinding -import dev.msfjarvis.aps.util.extensions.viewBinding - -/** - * [BottomSheetDialogFragment] that exposes a simple [androidx.appcompat.app.AlertDialog] like API - * through [BasicBottomSheet.Builder] to create a similar UI, just at the bottom of the screen. - */ -class BasicBottomSheet -private constructor( - val title: String?, - val message: String, - val positiveButtonLabel: String?, - val negativeButtonLabel: String?, - val positiveButtonClickListener: View.OnClickListener?, - val negativeButtonClickListener: View.OnClickListener?, -) : BottomSheetDialogFragment() { - - private val binding by viewBinding(BasicBottomSheetBinding::bind) - - private var behavior: BottomSheetBehavior? = null - private val bottomSheetCallback = - object : BottomSheetBehavior.BottomSheetCallback() { - override fun onSlide(bottomSheet: View, slideOffset: Float) {} - - override fun onStateChanged(bottomSheet: View, newState: Int) { - if (newState == BottomSheetBehavior.STATE_COLLAPSED) { - dismiss() - } - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - if (savedInstanceState != null) dismiss() - return layoutInflater.inflate(R.layout.basic_bottom_sheet, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - view.viewTreeObserver.addOnGlobalLayoutListener( - object : ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - view.viewTreeObserver.removeOnGlobalLayoutListener(this) - val dialog = dialog as BottomSheetDialog? ?: return - behavior = dialog.behavior - behavior?.apply { - state = BottomSheetBehavior.STATE_EXPANDED - peekHeight = 0 - addBottomSheetCallback(bottomSheetCallback) - } - if (!title.isNullOrEmpty()) { - binding.bottomSheetTitle.isVisible = true - binding.bottomSheetTitle.text = title - } - binding.bottomSheetMessage.text = message - if (positiveButtonClickListener != null) { - positiveButtonLabel?.let { buttonLbl -> binding.bottomSheetOkButton.text = buttonLbl } - binding.bottomSheetOkButton.isVisible = true - binding.bottomSheetOkButton.setOnClickListener { - positiveButtonClickListener.onClick(it) - dismiss() - } - } - if (negativeButtonClickListener != null) { - binding.bottomSheetCancelButton.isVisible = true - negativeButtonLabel?.let { buttonLbl -> - binding.bottomSheetCancelButton.text = buttonLbl - } - binding.bottomSheetCancelButton.setOnClickListener { - negativeButtonClickListener.onClick(it) - dismiss() - } - } - } - } - ) - } - - override fun dismiss() { - super.dismiss() - behavior?.removeBottomSheetCallback(bottomSheetCallback) - } - - class Builder(val context: Context) { - - private var title: String? = null - private var message: String? = null - private var positiveButtonLabel: String? = null - private var negativeButtonLabel: String? = null - private var positiveButtonClickListener: View.OnClickListener? = null - private var negativeButtonClickListener: View.OnClickListener? = null - - fun setTitleRes(@StringRes titleRes: Int): Builder { - this.title = context.resources.getString(titleRes) - return this - } - - fun setTitle(title: String): Builder { - this.title = title - return this - } - - fun setMessageRes(@StringRes messageRes: Int): Builder { - this.message = context.resources.getString(messageRes) - return this - } - - fun setMessage(message: String): Builder { - this.message = message - return this - } - - fun setPositiveButtonClickListener( - buttonLabel: String? = null, - listener: View.OnClickListener - ): Builder { - this.positiveButtonClickListener = listener - this.positiveButtonLabel = buttonLabel - return this - } - - fun setNegativeButtonClickListener( - buttonLabel: String? = null, - listener: View.OnClickListener - ): Builder { - this.negativeButtonClickListener = listener - this.negativeButtonLabel = buttonLabel - return this - } - - fun build(): BasicBottomSheet { - require(message != null) { "Message needs to be set" } - return BasicBottomSheet( - title, - message!!, - positiveButtonLabel, - negativeButtonLabel, - positiveButtonClickListener, - negativeButtonClickListener - ) - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt deleted file mode 100644 index 22f991c5..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.dialogs - -import android.app.AlertDialog -import android.app.Dialog -import android.content.SharedPreferences -import android.graphics.Typeface -import android.os.Bundle -import androidx.core.content.edit -import androidx.core.os.bundleOf -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import androidx.lifecycle.lifecycleScope -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.databinding.FragmentPwgenDicewareBinding -import dev.msfjarvis.aps.injection.prefs.PasswordGeneratorPreferences -import dev.msfjarvis.aps.passgen.diceware.DicewarePassphraseGenerator -import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivity -import dev.msfjarvis.aps.util.extensions.getString -import dev.msfjarvis.aps.util.settings.PreferenceKeys.DICEWARE_LENGTH -import dev.msfjarvis.aps.util.settings.PreferenceKeys.DICEWARE_SEPARATOR -import javax.inject.Inject -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.onEach -import reactivecircus.flowbinding.android.widget.afterTextChanges - -@AndroidEntryPoint -class DicewarePasswordGeneratorDialogFragment : DialogFragment() { - - @Inject lateinit var dicewareGenerator: DicewarePassphraseGenerator - @Inject @PasswordGeneratorPreferences lateinit var prefs: SharedPreferences - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = MaterialAlertDialogBuilder(requireContext()) - - val binding = FragmentPwgenDicewareBinding.inflate(layoutInflater) - builder.setView(binding.root) - - binding.passwordSeparatorText.setText(prefs.getString(DICEWARE_SEPARATOR) ?: "-") - binding.passwordLengthText.setText(prefs.getInt(DICEWARE_LENGTH, 5).toString()) - binding.passwordText.typeface = Typeface.MONOSPACE - - merge( - binding.passwordLengthText.afterTextChanges(), - binding.passwordSeparatorText.afterTextChanges(), - ) - .onEach { generatePassword(binding) } - .launchIn(lifecycleScope) - return builder - .run { - setTitle(R.string.pwgen_title) - setPositiveButton(R.string.dialog_ok) { _, _ -> - setFragmentResult( - PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY, - bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}") - ) - } - setNeutralButton(R.string.dialog_cancel) { _, _ -> } - setNegativeButton(R.string.pwgen_generate, null) - create() - } - .apply { - setOnShowListener { - generatePassword(binding) - getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { generatePassword(binding) } - } - } - } - - private fun generatePassword(binding: FragmentPwgenDicewareBinding) { - val length = binding.passwordLengthText.text?.toString()?.toIntOrNull() ?: 5 - val separator = binding.passwordSeparatorText.text?.toString()?.getOrNull(0) ?: '-' - setPreferences(length, separator) - binding.passwordText.text = dicewareGenerator.generatePassphrase(length, separator) - } - - private fun setPreferences(length: Int, separator: Char) { - prefs.edit { - putInt(DICEWARE_LENGTH, length) - putString(DICEWARE_SEPARATOR, separator.toString()) - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/FolderCreationDialogFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/FolderCreationDialogFragment.kt deleted file mode 100644 index 9b7b3e21..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/FolderCreationDialogFragment.kt +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.ui.dialogs - -import android.app.Dialog -import android.content.Intent -import android.os.Bundle -import android.view.WindowManager -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatActivity -import androidx.core.os.bundleOf -import androidx.fragment.app.DialogFragment -import androidx.lifecycle.lifecycleScope -import com.google.android.material.checkbox.MaterialCheckBox -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.repo.PasswordRepository -import dev.msfjarvis.aps.ui.crypto.BasePgpActivity -import dev.msfjarvis.aps.ui.crypto.GetKeyIdsActivity -import dev.msfjarvis.aps.ui.passwords.PasswordStore -import dev.msfjarvis.aps.util.extensions.commitChange -import java.io.File -import kotlinx.coroutines.launch -import me.msfjarvis.openpgpktx.util.OpenPgpApi - -class FolderCreationDialogFragment : DialogFragment() { - - private lateinit var newFolder: File - - private val keySelectAction = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == AppCompatActivity.RESULT_OK) { - result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds -> - val gpgIdentifierFile = File(newFolder, ".gpg-id") - gpgIdentifierFile.writeText(keyIds.joinToString("\n")) - if (PasswordRepository.repository != null) { - lifecycleScope.launch { - val repoPath = PasswordRepository.getRepositoryDirectory().absolutePath - requireActivity() - .commitChange( - getString( - R.string.git_commit_gpg_id, - BasePgpActivity.getLongName( - gpgIdentifierFile.parentFile!!.absolutePath, - repoPath, - gpgIdentifierFile.name - ) - ), - ) - dismiss() - } - } - } - } - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext()) - alertDialogBuilder.setTitle(R.string.title_create_folder) - alertDialogBuilder.setView(R.layout.folder_dialog_fragment) - alertDialogBuilder.setPositiveButton(getString(R.string.button_create), null) - alertDialogBuilder.setNegativeButton(getString(android.R.string.cancel)) { _, _ -> dismiss() } - val dialog = alertDialogBuilder.create() - dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - dialog.setOnShowListener { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { - createDirectory(requireArguments().getString(CURRENT_DIR_EXTRA)!!) - } - } - return dialog - } - - private fun createDirectory(currentDir: String) { - val dialog = requireDialog() - val folderNameView = dialog.findViewById(R.id.folder_name_text) - val folderNameViewContainer = dialog.findViewById(R.id.folder_name_container) - newFolder = File("$currentDir/${folderNameView.text}") - folderNameViewContainer.error = - when { - newFolder.isFile -> getString(R.string.folder_creation_err_file_exists) - newFolder.isDirectory -> getString(R.string.folder_creation_err_folder_exists) - else -> null - } - if (folderNameViewContainer.error != null) return - newFolder.mkdirs() - (requireActivity() as PasswordStore).refreshPasswordList(newFolder) - if (dialog.findViewById(R.id.set_gpg_key).isChecked) { - keySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java)) - return - } else { - dismiss() - } - } - - companion object { - - private const val CURRENT_DIR_EXTRA = "CURRENT_DIRECTORY" - fun newInstance(startingDirectory: String): FolderCreationDialogFragment { - val extras = bundleOf(CURRENT_DIR_EXTRA to startingDirectory) - val fragment = FolderCreationDialogFragment() - fragment.arguments = extras - return fragment - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/ItemCreationBottomSheet.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/ItemCreationBottomSheet.kt deleted file mode 100644 index af2a5f19..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/ItemCreationBottomSheet.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.ui.dialogs - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewTreeObserver -import android.widget.FrameLayout -import androidx.core.os.bundleOf -import androidx.fragment.app.setFragmentResult -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.ui.passwords.PasswordFragment.Companion.ACTION_FOLDER -import dev.msfjarvis.aps.ui.passwords.PasswordFragment.Companion.ACTION_KEY -import dev.msfjarvis.aps.ui.passwords.PasswordFragment.Companion.ACTION_PASSWORD -import dev.msfjarvis.aps.ui.passwords.PasswordFragment.Companion.ITEM_CREATION_REQUEST_KEY - -class ItemCreationBottomSheet : BottomSheetDialogFragment() { - - private var behavior: BottomSheetBehavior? = null - private val bottomSheetCallback = - object : BottomSheetBehavior.BottomSheetCallback() { - override fun onSlide(bottomSheet: View, slideOffset: Float) {} - - override fun onStateChanged(bottomSheet: View, newState: Int) { - if (newState == BottomSheetBehavior.STATE_COLLAPSED) { - dismiss() - } - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - if (savedInstanceState != null) dismiss() - return inflater.inflate(R.layout.item_create_sheet, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - view.viewTreeObserver.addOnGlobalLayoutListener( - object : ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - view.viewTreeObserver.removeOnGlobalLayoutListener(this) - val dialog = dialog as BottomSheetDialog? ?: return - behavior = dialog.behavior - behavior?.apply { - state = BottomSheetBehavior.STATE_EXPANDED - peekHeight = 0 - addBottomSheetCallback(bottomSheetCallback) - } - dialog.findViewById(R.id.create_folder)?.setOnClickListener { - setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_FOLDER)) - dismiss() - } - dialog.findViewById(R.id.create_password)?.setOnClickListener { - setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_PASSWORD)) - dismiss() - } - } - } - ) - } - - override fun dismiss() { - super.dismiss() - behavior?.removeBottomSheetCallback(bottomSheetCallback) - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/OtpImportDialogFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/OtpImportDialogFragment.kt deleted file mode 100644 index 1670d263..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/OtpImportDialogFragment.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.dialogs - -import android.app.Dialog -import android.net.Uri -import android.os.Bundle -import android.view.WindowManager -import androidx.core.os.bundleOf -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dev.msfjarvis.aps.databinding.FragmentManualOtpEntryBinding -import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivity - -class OtpImportDialogFragment : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = MaterialAlertDialogBuilder(requireContext()) - val binding = FragmentManualOtpEntryBinding.inflate(layoutInflater) - builder.setView(binding.root) - builder.setPositiveButton(android.R.string.ok) { _, _ -> - setFragmentResult( - PasswordCreationActivity.OTP_RESULT_REQUEST_KEY, - bundleOf(PasswordCreationActivity.RESULT to getTOTPUri(binding)) - ) - } - val dialog = builder.create() - dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - return dialog - } - - private fun getTOTPUri(binding: FragmentManualOtpEntryBinding): String { - val secret = binding.secret.text.toString() - val account = binding.account.text.toString() - if (secret.isBlank()) return "" - val builder = Uri.Builder() - builder.scheme("otpauth") - builder.authority("totp") - builder.appendQueryParameter("secret", secret) - if (account.isNotBlank()) builder.appendQueryParameter("issuer", account) - return builder.build().toString() - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt deleted file mode 100644 index e14076b1..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.ui.dialogs - -import android.app.AlertDialog -import android.app.Dialog -import android.content.Context -import android.graphics.Typeface -import android.os.Bundle -import android.widget.CheckBox -import android.widget.EditText -import android.widget.Toast -import androidx.annotation.IdRes -import androidx.appcompat.widget.AppCompatTextView -import androidx.core.content.edit -import androidx.core.os.bundleOf -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import androidx.lifecycle.lifecycleScope -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.databinding.FragmentPwgenBinding -import dev.msfjarvis.aps.passgen.random.MaxIterationsExceededException -import dev.msfjarvis.aps.passgen.random.NoCharactersIncludedException -import dev.msfjarvis.aps.passgen.random.PasswordGenerator -import dev.msfjarvis.aps.passgen.random.PasswordLengthTooShortException -import dev.msfjarvis.aps.passgen.random.PasswordOption -import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivity -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.onEach -import reactivecircus.flowbinding.android.widget.afterTextChanges -import reactivecircus.flowbinding.android.widget.checkedChanges - -class PasswordGeneratorDialogFragment : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val prefs = requireContext().getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE) - val builder = MaterialAlertDialogBuilder(requireContext()) - - val binding = FragmentPwgenBinding.inflate(layoutInflater) - builder.setView(binding.root) - - binding.numerals.isChecked = !prefs.getBoolean(PasswordOption.NoDigits.key, false) - binding.symbols.isChecked = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false) - binding.uppercase.isChecked = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false) - binding.lowercase.isChecked = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false) - binding.ambiguous.isChecked = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false) - binding.pronounceable.isChecked = !prefs.getBoolean(PasswordOption.FullyRandom.key, true) - binding.lengthNumber.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString()) - binding.passwordText.typeface = Typeface.MONOSPACE - - merge( - binding.numerals.checkedChanges().skipInitialValue(), - binding.symbols.checkedChanges().skipInitialValue(), - binding.uppercase.checkedChanges().skipInitialValue(), - binding.lowercase.checkedChanges().skipInitialValue(), - binding.ambiguous.checkedChanges().skipInitialValue(), - binding.pronounceable.checkedChanges().skipInitialValue(), - binding.lengthNumber.afterTextChanges().skipInitialValue(), - ) - .onEach { generate(binding.passwordText) } - .launchIn(lifecycleScope) - - return builder - .run { - setTitle(R.string.pwgen_title) - setPositiveButton(R.string.dialog_ok) { _, _ -> - setFragmentResult( - PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY, - bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}") - ) - } - setNeutralButton(R.string.dialog_cancel) { _, _ -> } - setNegativeButton(R.string.pwgen_generate, null) - create() - } - .apply { - setOnShowListener { - generate(binding.passwordText) - getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { - generate(binding.passwordText) - } - } - } - } - - private fun generate(passwordField: AppCompatTextView) { - val passwordOptions = getSelectedOptions() - val passwordLength = getLength() - setPrefs(requireContext(), passwordOptions, passwordLength) - passwordField.text = - runCatching { PasswordGenerator.generate(passwordOptions, passwordLength) } - .getOrElse { exception -> - val errorText = - when (exception) { - is MaxIterationsExceededException -> - requireContext().getString(R.string.pwgen_max_iterations_exceeded) - is NoCharactersIncludedException -> - requireContext().getString(R.string.pwgen_no_chars_error) - is PasswordLengthTooShortException -> - requireContext().getString(R.string.pwgen_length_too_short_error) - else -> requireContext().getString(R.string.pwgen_some_error_occurred) - } - Toast.makeText(requireActivity(), errorText, Toast.LENGTH_SHORT).show() - "" - } - } - - private fun isChecked(@IdRes id: Int): Boolean { - return requireDialog().findViewById(id).isChecked - } - - private fun getSelectedOptions(): List { - return listOfNotNull( - PasswordOption.NoDigits.takeIf { !isChecked(R.id.numerals) }, - PasswordOption.AtLeastOneSymbol.takeIf { isChecked(R.id.symbols) }, - PasswordOption.NoUppercaseLetters.takeIf { !isChecked(R.id.uppercase) }, - PasswordOption.NoAmbiguousCharacters.takeIf { !isChecked(R.id.ambiguous) }, - PasswordOption.FullyRandom.takeIf { !isChecked(R.id.pronounceable) }, - PasswordOption.NoLowercaseLetters.takeIf { !isChecked(R.id.lowercase) } - ) - } - - private fun getLength(): Int { - val lengthText = requireDialog().findViewById(R.id.lengthNumber).text.toString() - return lengthText.toIntOrNull()?.takeIf { it >= 0 } ?: PasswordGenerator.DEFAULT_LENGTH - } - - /** - * Enables the [PasswordOption]s in [options] and sets [targetLength] as the length for generated - * passwords. - */ - private fun setPrefs(ctx: Context, options: List, targetLength: Int): Boolean { - ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit { - for (possibleOption in PasswordOption.values()) putBoolean( - possibleOption.key, - possibleOption in options - ) - putInt("length", targetLength) - } - return true - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderActivity.kt deleted file mode 100644 index 48ed5b79..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderActivity.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.ui.folderselect - -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.commit -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.repo.PasswordRepository -import dev.msfjarvis.aps.ui.passwords.PASSWORD_FRAGMENT_TAG -import dev.msfjarvis.aps.ui.passwords.PasswordStore - -class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) { - - private lateinit var passwordList: SelectFolderFragment - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - passwordList = SelectFolderFragment() - val args = Bundle() - args.putString( - PasswordStore.REQUEST_ARG_PATH, - PasswordRepository.getRepositoryDirectory().absolutePath - ) - - passwordList.arguments = args - - supportActionBar?.show() - - supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) - - supportFragmentManager.commit { - replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG) - } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.pgp_handler_select_folder, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - setResult(RESULT_CANCELED) - onBackPressed() - } - R.id.crypto_select -> selectFolder() - else -> return super.onOptionsItemSelected(item) - } - return true - } - - private fun selectFolder() { - intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir.absolutePath) - setResult(RESULT_OK, intent) - finish() - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderFragment.kt deleted file mode 100644 index cae55ba1..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderFragment.kt +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.ui.folderselect - -import android.content.Context -import android.os.Bundle -import android.view.View -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.password.PasswordItem -import dev.msfjarvis.aps.databinding.PasswordRecyclerViewBinding -import dev.msfjarvis.aps.ui.adapters.PasswordItemRecyclerAdapter -import dev.msfjarvis.aps.ui.passwords.PasswordStore -import dev.msfjarvis.aps.util.extensions.viewBinding -import dev.msfjarvis.aps.util.viewmodel.ListMode -import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryViewModel -import java.io.File -import me.zhanghai.android.fastscroll.FastScrollerBuilder - -class SelectFolderFragment : Fragment(R.layout.password_recycler_view) { - - private val binding by viewBinding(PasswordRecyclerViewBinding::bind) - private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter - private lateinit var listener: OnFragmentInteractionListener - - private val model: SearchableRepositoryViewModel by activityViewModels() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.fab.hide() - recyclerAdapter = - PasswordItemRecyclerAdapter(lifecycleScope).onItemClicked { _, item -> - listener.onFragmentInteraction(item) - } - binding.passRecycler.apply { - layoutManager = LinearLayoutManager(requireContext()) - itemAnimator = null - adapter = recyclerAdapter - } - - FastScrollerBuilder(binding.passRecycler).build() - registerForContextMenu(binding.passRecycler) - - val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) - model.navigateTo(File(path), listMode = ListMode.DirectoriesOnly, pushPreviousLocation = false) - model.searchResult.observe(viewLifecycleOwner) { result -> - recyclerAdapter.submitList(result.passwordItems) - } - } - - override fun onAttach(context: Context) { - super.onAttach(context) - runCatching { - listener = - object : OnFragmentInteractionListener { - override fun onFragmentInteraction(item: PasswordItem) { - if (item.type == PasswordItem.TYPE_CATEGORY) { - model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly) - (requireActivity() as AppCompatActivity) - .supportActionBar - ?.setDisplayHomeAsUpEnabled(true) - } - } - } - } - .onFailure { - throw ClassCastException("$context must implement OnFragmentInteractionListener") - } - } - - val currentDir: File - get() = model.currentDir.value!! - - interface OnFragmentInteractionListener { - - fun onFragmentInteraction(item: PasswordItem) - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/base/BaseGitActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/base/BaseGitActivity.kt deleted file mode 100644 index 614d9a52..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/git/base/BaseGitActivity.kt +++ /dev/null @@ -1,185 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.ui.git.base - -import android.content.SharedPreferences -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import com.github.michaelbull.result.Err -import com.github.michaelbull.result.Result -import com.github.michaelbull.result.andThen -import com.github.michaelbull.result.mapError -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.injection.prefs.GitPreferences -import dev.msfjarvis.aps.util.extensions.sharedPrefs -import dev.msfjarvis.aps.util.git.ErrorMessages -import dev.msfjarvis.aps.util.git.operation.BreakOutOfDetached -import dev.msfjarvis.aps.util.git.operation.CloneOperation -import dev.msfjarvis.aps.util.git.operation.GcOperation -import dev.msfjarvis.aps.util.git.operation.PullOperation -import dev.msfjarvis.aps.util.git.operation.PushOperation -import dev.msfjarvis.aps.util.git.operation.ResetToRemoteOperation -import dev.msfjarvis.aps.util.git.operation.SyncOperation -import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity -import dev.msfjarvis.aps.util.settings.GitSettings -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import logcat.asLog -import logcat.logcat -import net.schmizz.sshj.common.DisconnectReason -import net.schmizz.sshj.common.SSHException -import net.schmizz.sshj.transport.TransportException -import net.schmizz.sshj.userauth.UserAuthException - -/** - * Abstract [AppCompatActivity] that holds some information that is commonly shared across - * git-related tasks and makes sense to be held here. - */ -@AndroidEntryPoint -abstract class BaseGitActivity : ContinuationContainerActivity() { - - /** Enum of possible Git operations than can be run through [launchGitOperation]. */ - enum class GitOp { - BREAK_OUT_OF_DETACHED, - CLONE, - PULL, - PUSH, - RESET, - SYNC, - GC, - } - - @Inject lateinit var gitSettings: GitSettings - @GitPreferences @Inject lateinit var gitPrefs: SharedPreferences - - /** - * Attempt to launch the requested Git operation. - * @param operation The type of git operation to launch - */ - suspend fun launchGitOperation(operation: GitOp): Result { - if (gitSettings.url == null) { - return Err(IllegalStateException("Git url is not set!")) - } - if (operation == GitOp.SYNC && !gitSettings.useMultiplexing) { - // If the server does not support multiple SSH channels per connection, we cannot run - // a sync operation without reconnecting and thus break sync into its two parts. - return launchGitOperation(GitOp.PULL).andThen { launchGitOperation(GitOp.PUSH) } - } - val op = - when (operation) { - GitOp.CLONE -> CloneOperation(this, gitSettings.url!!) - GitOp.PULL -> PullOperation(this, gitSettings.rebaseOnPull) - GitOp.PUSH -> PushOperation(this) - GitOp.SYNC -> SyncOperation(this, gitSettings.rebaseOnPull) - GitOp.BREAK_OUT_OF_DETACHED -> BreakOutOfDetached(this) - GitOp.RESET -> ResetToRemoteOperation(this) - GitOp.GC -> GcOperation(this) - } - return (if (op.requiresAuth) { - op.executeAfterAuthentication(gitSettings.authMode) - } else { - op.execute() - }) - .mapError(::transformGitError) - } - - fun finishOnSuccessHandler(@Suppress("UNUSED_PARAMETER") nothing: Unit) { - finish() - } - - suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) { - val error = rootCauseException(err) - if (!isExplicitlyUserInitiatedError(error)) { - gitPrefs.edit { remove(PreferenceKeys.HTTPS_PASSWORD) } - sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } - logcat { error.asLog() } - withContext(Dispatchers.Main) { - MaterialAlertDialogBuilder(this@BaseGitActivity).run { - setTitle(resources.getString(R.string.jgit_error_dialog_title)) - setMessage(ErrorMessages[error]) - setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> } - setOnDismissListener { onPromptDone() } - show() - } - } - } else { - onPromptDone() - } - } - - /** - * Takes the result of [launchGitOperation] and applies any necessary transformations on the - * [throwable] returned from it - */ - private fun transformGitError(throwable: Throwable): Throwable { - val err = rootCauseException(throwable) - return when { - err.message?.contains("cannot open additional channels") == true -> { - gitSettings.useMultiplexing = false - SSHException( - DisconnectReason.TOO_MANY_CONNECTIONS, - "The server does not support multiple Git operations per SSH session. Please try again, a slower fallback mode will be used." - ) - } - err.message?.contains("int org.eclipse.jgit.lib.AnyObjectId.w1") == true -> { - IllegalStateException( - "Your local repository appears to be an incomplete Git clone, please delete and re-clone from settings" - ) - } - err is TransportException && - err.disconnectReason == DisconnectReason.HOST_KEY_NOT_VERIFIABLE -> { - SSHException( - DisconnectReason.HOST_KEY_NOT_VERIFIABLE, - "WARNING: The remote host key has changed. If this is expected, please go to Git server settings and clear the saved host key." - ) - } - else -> { - err - } - } - } - - /** - * Check if a given [Throwable] is the result of an error caused by the user cancelling the - * operation. - */ - private fun isExplicitlyUserInitiatedError(throwable: Throwable): Boolean { - var cause: Throwable? = throwable - while (cause != null) { - if ( - cause is SSHException && cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER - ) - return true - cause = cause.cause - } - return false - } - - /** - * Get the real root cause of a [Throwable] by traversing until known wrapping exceptions are no - * longer found. - */ - private fun rootCauseException(throwable: Throwable): Throwable { - var rootCause = throwable - // JGit's InvalidRemoteException and TransportException hide the more helpful SSHJ - // exceptions. - // Also, SSHJ's UserAuthException about exhausting available authentication methods hides - // more useful exceptions. - while ( - (rootCause is org.eclipse.jgit.errors.TransportException || - rootCause is org.eclipse.jgit.api.errors.TransportException || - rootCause is org.eclipse.jgit.api.errors.InvalidRemoteException || - (rootCause is UserAuthException && - rootCause.message == "Exhausted available authentication methods")) - ) { - rootCause = rootCause.cause ?: break - } - return rootCause - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitConfigActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitConfigActivity.kt deleted file mode 100644 index 03c92313..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitConfigActivity.kt +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.ui.git.config - -import android.content.Intent -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.util.Patterns -import android.view.MenuItem -import androidx.core.os.postDelayed -import androidx.lifecycle.lifecycleScope -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.repo.PasswordRepository -import dev.msfjarvis.aps.databinding.ActivityGitConfigBinding -import dev.msfjarvis.aps.ui.git.base.BaseGitActivity -import dev.msfjarvis.aps.ui.git.log.GitLogActivity -import dev.msfjarvis.aps.util.extensions.viewBinding -import kotlinx.coroutines.launch -import logcat.LogPriority.ERROR -import logcat.logcat -import org.eclipse.jgit.lib.Constants -import org.eclipse.jgit.lib.Repository -import org.eclipse.jgit.lib.RepositoryState - -class GitConfigActivity : BaseGitActivity() { - - private val binding by viewBinding(ActivityGitConfigBinding::inflate) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - if (gitSettings.authorName.isEmpty()) binding.gitUserName.requestFocus() - else binding.gitUserName.setText(gitSettings.authorName) - binding.gitUserEmail.setText(gitSettings.authorEmail) - setupTools() - binding.saveButton.setOnClickListener { - val email = binding.gitUserEmail.text.toString().trim() - val name = binding.gitUserName.text.toString().trim() - if (!email.matches(Patterns.EMAIL_ADDRESS.toRegex())) { - MaterialAlertDialogBuilder(this) - .setMessage(getString(R.string.invalid_email_dialog_text)) - .setPositiveButton(getString(R.string.dialog_ok), null) - .show() - } else { - gitSettings.authorEmail = email - gitSettings.authorName = name - Snackbar.make( - binding.root, - getString(R.string.git_server_config_save_success), - Snackbar.LENGTH_SHORT - ) - .show() - Handler(Looper.getMainLooper()).postDelayed(500) { finish() } - } - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - onBackPressed() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - /** Sets up the UI components of the tools section. */ - private fun setupTools() { - val repo = PasswordRepository.repository - if (repo != null) { - binding.gitHeadStatus.text = headStatusMsg(repo) - // enable the abort button only if we're rebasing or merging - val needsAbort = - repo.repositoryState.isRebasing || repo.repositoryState == RepositoryState.MERGING - binding.gitAbortRebase.isEnabled = needsAbort - binding.gitAbortRebase.alpha = if (needsAbort) 1.0f else 0.5f - } - binding.gitLog.setOnClickListener { - runCatching { startActivity(Intent(this, GitLogActivity::class.java)) } - .onFailure { ex -> logcat(ERROR) { "Failed to start GitLogActivity\n${ex}" } } - } - binding.gitAbortRebase.setOnClickListener { - lifecycleScope.launch { - launchGitOperation(GitOp.BREAK_OUT_OF_DETACHED) - .fold( - success = { - MaterialAlertDialogBuilder(this@GitConfigActivity).run { - setTitle(resources.getString(R.string.git_abort_and_push_title)) - setMessage( - resources.getString( - R.string.git_break_out_of_detached_success, - gitSettings.branch, - "conflicting-${gitSettings.branch}-...", - ) - ) - setOnDismissListener { finish() } - setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> } - show() - } - }, - failure = { err -> promptOnErrorHandler(err) { finish() } }, - ) - } - } - binding.gitResetToRemote.setOnClickListener { - lifecycleScope.launch { - launchGitOperation(GitOp.RESET) - .fold( - success = ::finishOnSuccessHandler, - failure = { err -> promptOnErrorHandler(err) { finish() } }, - ) - } - } - binding.gitGc.setOnClickListener { - lifecycleScope.launch { - launchGitOperation(GitOp.GC) - .fold( - success = ::finishOnSuccessHandler, - failure = { err -> promptOnErrorHandler(err) { finish() } }, - ) - } - } - } - - /** - * Returns a user-friendly message about the current state of HEAD. - * - * The state is recognized to be either pointing to a branch or detached. - */ - private fun headStatusMsg(repo: Repository): String { - return runCatching { - val headRef = repo.getRef(Constants.HEAD) - if (headRef.isSymbolic) { - val branchName = headRef.target.name - val shortBranchName = Repository.shortenRefName(branchName) - getString(R.string.git_head_on_branch, shortBranchName) - } else { - val commitHash = headRef.objectId.abbreviate(8).name() - getString(R.string.git_head_detached, commitHash) - } - } - .getOrElse { ex -> - logcat(ERROR) { "Error getting HEAD reference\n${ex}" } - getString(R.string.git_head_missing) - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitServerConfigActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitServerConfigActivity.kt deleted file mode 100644 index 93f104fb..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitServerConfigActivity.kt +++ /dev/null @@ -1,305 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.ui.git.config - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.view.MenuItem -import android.view.View -import androidx.core.os.postDelayed -import androidx.core.view.isVisible -import androidx.core.widget.doOnTextChanged -import androidx.lifecycle.lifecycleScope -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.repo.PasswordRepository -import dev.msfjarvis.aps.databinding.ActivityGitCloneBinding -import dev.msfjarvis.aps.ui.dialogs.BasicBottomSheet -import dev.msfjarvis.aps.ui.git.base.BaseGitActivity -import dev.msfjarvis.aps.util.extensions.snackbar -import dev.msfjarvis.aps.util.extensions.viewBinding -import dev.msfjarvis.aps.util.settings.AuthMode -import dev.msfjarvis.aps.util.settings.GitSettings -import dev.msfjarvis.aps.util.settings.Protocol -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat - -/** - * Activity that encompasses both the initial clone as well as editing the server config for future - * changes. - */ -class GitServerConfigActivity : BaseGitActivity() { - - private val binding by viewBinding(ActivityGitCloneBinding::inflate) - - private lateinit var newAuthMode: AuthMode - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val isClone = intent?.extras?.getBoolean("cloning") ?: false - if (isClone) { - binding.saveButton.text = getString(R.string.clone_button) - } - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - newAuthMode = gitSettings.authMode - - binding.authModeGroup.apply { - when (newAuthMode) { - AuthMode.SshKey -> check(binding.authModeSshKey.id) - AuthMode.Password -> check(binding.authModePassword.id) - AuthMode.OpenKeychain -> check(binding.authModeOpenKeychain.id) - AuthMode.None -> check(View.NO_ID) - } - addOnButtonCheckedListener { _, checkedId, isChecked -> - if (!isChecked) { - newAuthMode = AuthMode.None - return@addOnButtonCheckedListener - } - when (checkedId) { - binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey - binding.authModeOpenKeychain.id -> newAuthMode = AuthMode.OpenKeychain - binding.authModePassword.id -> newAuthMode = AuthMode.Password - View.NO_ID -> newAuthMode = AuthMode.None - } - } - } - - binding.serverUrl.setText( - gitSettings.url.also { - if (it.isNullOrEmpty()) return@also - setAuthModes(it.startsWith("http://") || it.startsWith("https://")) - } - ) - binding.serverBranch.setText(gitSettings.branch) - - binding.serverUrl.doOnTextChanged { text, _, _, _ -> - if (text.isNullOrEmpty()) return@doOnTextChanged - setAuthModes(text.startsWith("http://") || text.startsWith("https://")) - } - - binding.clearHostKeyButton.isVisible = gitSettings.hasSavedHostKey() - binding.clearHostKeyButton.setOnClickListener { - gitSettings.clearSavedHostKey() - Snackbar.make( - binding.root, - getString(R.string.clear_saved_host_key_success), - Snackbar.LENGTH_LONG - ) - .show() - it.isVisible = false - } - binding.saveButton.setOnClickListener { - val newUrl = binding.serverUrl.text.toString().trim() - // If url is of type john_doe@example.org:12435/path/to/repo, then not adding `ssh://` - // in the beginning will cause the port to be seen as part of the path. Let users know - // about it and offer a quickfix. - if (newUrl.contains(PORT_REGEX)) { - if (newUrl.startsWith("https://")) { - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.https_scheme_with_port_title) - .setMessageRes(R.string.https_scheme_with_port_message) - .setPositiveButtonClickListener { - binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/")) - } - .build() - .show(supportFragmentManager, "SSH_SCHEME_WARNING") - return@setOnClickListener - } else if (!newUrl.startsWith("ssh://")) { - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.ssh_scheme_needed_title) - .setMessageRes(R.string.ssh_scheme_needed_message) - .setPositiveButtonClickListener { - @Suppress("SetTextI18n") binding.serverUrl.setText("ssh://$newUrl") - } - .build() - .show(supportFragmentManager, "SSH_SCHEME_WARNING") - return@setOnClickListener - } - } - if (newUrl.startsWith("git://")) { - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.git_scheme_disallowed_title) - .setMessageRes(R.string.git_scheme_disallowed_message) - .setPositiveButtonClickListener {} - .build() - .show(supportFragmentManager, "SSH_SCHEME_WARNING") - return@setOnClickListener - } - when ( - val updateResult = - gitSettings.updateConnectionSettingsIfValid( - newAuthMode = newAuthMode, - newUrl = binding.serverUrl.text.toString().trim(), - newBranch = binding.serverBranch.text.toString().trim() - ) - ) { - GitSettings.UpdateConnectionSettingsResult.FailedToParseUrl -> { - Snackbar.make( - binding.root, - getString(R.string.git_server_config_save_error), - Snackbar.LENGTH_LONG - ) - .show() - } - is GitSettings.UpdateConnectionSettingsResult.MissingUsername -> { - when (updateResult.newProtocol) { - Protocol.Https -> - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.ssh_scheme_needed_title) - .setMessageRes(R.string.git_server_config_save_missing_username_https) - .setPositiveButtonClickListener {} - .build() - .show(supportFragmentManager, "HTTPS_MISSING_USERNAME") - Protocol.Ssh -> - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.ssh_scheme_needed_title) - .setMessageRes(R.string.git_server_config_save_missing_username_ssh) - .setPositiveButtonClickListener {} - .build() - .show(supportFragmentManager, "SSH_MISSING_USERNAME") - } - } - GitSettings.UpdateConnectionSettingsResult.Valid -> { - if (isClone && PasswordRepository.repository == null) PasswordRepository.initialize() - if (!isClone) { - Snackbar.make( - binding.root, - getString(R.string.git_server_config_save_success), - Snackbar.LENGTH_SHORT - ) - .show() - Handler(Looper.getMainLooper()).postDelayed(500) { finish() } - } else { - cloneRepository() - } - } - is GitSettings.UpdateConnectionSettingsResult.AuthModeMismatch -> { - val message = - getString( - R.string.git_server_config_save_auth_mode_mismatch, - updateResult.newProtocol, - updateResult.validModes.joinToString(", "), - ) - Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show() - } - } - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - onBackPressed() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - private fun setAuthModes(isHttps: Boolean) = - with(binding) { - if (isHttps) { - authModeSshKey.isVisible = false - authModeOpenKeychain.isVisible = false - authModePassword.isVisible = true - if (authModeGroup.checkedButtonId != authModePassword.id) authModeGroup.check(View.NO_ID) - } else { - authModeSshKey.isVisible = true - authModeOpenKeychain.isVisible = true - authModePassword.isVisible = true - if (authModeGroup.checkedButtonId == View.NO_ID) authModeGroup.check(authModeSshKey.id) - } - } - - /** Clones the repository, the directory exists, deletes it */ - private fun cloneRepository() { - val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory()) - val localDirFiles = localDir.listFiles() ?: emptyArray() - // Warn if non-empty folder unless it's a just-initialized store that has just a .git folder - if ( - localDir.exists() && - localDirFiles.isNotEmpty() && - !(localDirFiles.size == 1 && localDirFiles[0].name == ".git") - ) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.dialog_delete_title) - .setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString())) - .setCancelable(false) - .setPositiveButton(R.string.dialog_delete) { dialog, _ -> - runCatching { - lifecycleScope.launch { - val snackbar = - snackbar( - message = getString(R.string.delete_directory_progress_text), - length = Snackbar.LENGTH_INDEFINITE - ) - withContext(Dispatchers.IO) { localDir.deleteRecursively() } - snackbar.dismiss() - launchGitOperation(GitOp.CLONE) - .fold( - success = { - setResult(RESULT_OK) - finish() - }, - failure = { err -> promptOnErrorHandler(err) { finish() } } - ) - } - } - .onFailure { e -> - e.printStackTrace() - MaterialAlertDialogBuilder(this).setMessage(e.message).show() - } - dialog.cancel() - } - .setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ -> dialog.cancel() } - .show() - } else { - runCatching { - // Silently delete & replace the lone .git folder if it exists - if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") { - localDir.deleteRecursively() - } - } - .onFailure { e -> - logcat(ERROR) { e.asLog() } - MaterialAlertDialogBuilder(this).setMessage(e.message).show() - } - lifecycleScope.launch { - launchGitOperation(GitOp.CLONE) - .fold( - success = { - setResult(RESULT_OK) - finish() - }, - failure = { promptOnErrorHandler(it) }, - ) - } - } - } - - companion object { - - private val PORT_REGEX = ":[0-9]{1,5}/".toRegex() - - fun createCloneIntent(context: Context): Intent { - return Intent(context, GitServerConfigActivity::class.java).apply { - putExtra("cloning", true) - } - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt deleted file mode 100644 index 4265717d..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.git.log - -import android.os.Bundle -import android.view.MenuItem -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import dev.msfjarvis.aps.databinding.ActivityGitLogBinding -import dev.msfjarvis.aps.ui.git.base.BaseGitActivity -import dev.msfjarvis.aps.util.extensions.viewBinding - -/** - * Displays the repository's git commits in git-log fashion. - * - * It provides basic information about each commit by way of a non-interactive RecyclerView. - */ -class GitLogActivity : BaseGitActivity() { - - private val binding by viewBinding(ActivityGitLogBinding::inflate) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - createRecyclerView() - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - finish() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - private fun createRecyclerView() { - binding.gitLogRecyclerView.apply { - setHasFixedSize(true) - addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL)) - adapter = GitLogAdapter() - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt deleted file mode 100644 index a3080fef..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.git.log - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import dev.msfjarvis.aps.databinding.GitLogRowLayoutBinding -import dev.msfjarvis.aps.util.git.GitCommit -import dev.msfjarvis.aps.util.git.GitLogModel -import java.text.DateFormat -import java.util.Date -import logcat.LogPriority.ERROR -import logcat.logcat - -private fun shortHash(hash: String): String { - return hash.substring(0 until 8) -} - -private fun stringFrom(date: Date): String { - return DateFormat.getDateTimeInstance().format(date) -} - -/** @see GitLogActivity */ -class GitLogAdapter : RecyclerView.Adapter() { - - private val model = GitLogModel() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val inflater = LayoutInflater.from(parent.context) - val binding = GitLogRowLayoutBinding.inflate(inflater, parent, false) - return ViewHolder(binding) - } - - override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { - val commit = model.get(position) - if (commit == null) { - logcat(ERROR) { "There is no git commit for view holder at position $position." } - return - } - viewHolder.bind(commit) - } - - override fun getItemCount() = model.size - - class ViewHolder(private val binding: GitLogRowLayoutBinding) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(commit: GitCommit) = - with(binding) { - gitLogRowMessage.text = commit.shortMessage - gitLogRowHash.text = shortHash(commit.hash) - gitLogRowTime.text = stringFrom(commit.time) - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/main/LaunchActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/main/LaunchActivity.kt deleted file mode 100644 index 17005b6a..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/main/LaunchActivity.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.ui.main - -import android.content.Intent -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import dagger.hilt.android.AndroidEntryPoint -import dev.msfjarvis.aps.ui.crypto.BasePgpActivity -import dev.msfjarvis.aps.ui.crypto.DecryptActivity -import dev.msfjarvis.aps.ui.crypto.DecryptActivityV2 -import dev.msfjarvis.aps.ui.passwords.PasswordStore -import dev.msfjarvis.aps.util.auth.BiometricAuthenticator -import dev.msfjarvis.aps.util.auth.BiometricAuthenticator.Result -import dev.msfjarvis.aps.util.extensions.sharedPrefs -import dev.msfjarvis.aps.util.features.Feature -import dev.msfjarvis.aps.util.features.Features -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import javax.inject.Inject - -@AndroidEntryPoint -class LaunchActivity : AppCompatActivity() { - - @Inject lateinit var features: Features - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val prefs = sharedPrefs - if (prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH, false)) { - BiometricAuthenticator.authenticate(this) { result -> - when (result) { - is Result.Success -> { - startTargetActivity(false) - } - is Result.HardwareUnavailableOrDisabled -> { - prefs.edit { remove(PreferenceKeys.BIOMETRIC_AUTH) } - startTargetActivity(false) - } - is Result.Failure, - Result.Cancelled -> { - finish() - } - is Result.Retry -> {} - } - } - } else { - startTargetActivity(true) - } - } - - private fun getDecryptIntent(): Intent { - return if (features.isEnabled(Feature.EnablePGPainlessBackend)) { - Intent(this, DecryptActivityV2::class.java) - } else { - Intent(this, DecryptActivity::class.java) - } - } - - private fun startTargetActivity(noAuth: Boolean) { - val intentToStart = - if (intent.action == ACTION_DECRYPT_PASS) - getDecryptIntent().apply { - putExtra( - BasePgpActivity.EXTRA_FILE_PATH, - intent.getStringExtra(BasePgpActivity.EXTRA_FILE_PATH) - ) - putExtra( - BasePgpActivity.EXTRA_REPO_PATH, - intent.getStringExtra(BasePgpActivity.EXTRA_REPO_PATH) - ) - } - else Intent(this, PasswordStore::class.java) - startActivity(intentToStart) - - Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (noAuth) 0L else 500L) - } - - companion object { - - const val ACTION_DECRYPT_PASS = "DECRYPT_PASS" - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/activity/OnboardingActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/activity/OnboardingActivity.kt deleted file mode 100644 index 31ca362c..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/activity/OnboardingActivity.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.onboarding.activity - -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import dev.msfjarvis.aps.R - -class OnboardingActivity : AppCompatActivity(R.layout.activity_onboarding) { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - supportActionBar?.hide() - } - - override fun onBackPressed() { - if (supportFragmentManager.backStackEntryCount == 0) { - finishAffinity() - } else { - super.onBackPressed() - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/CloneFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/CloneFragment.kt deleted file mode 100644 index e3d1df85..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/CloneFragment.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.onboarding.fragments - -import android.os.Bundle -import android.view.View -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.fragment.app.Fragment -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.repo.PasswordRepository -import dev.msfjarvis.aps.databinding.FragmentCloneBinding -import dev.msfjarvis.aps.ui.git.config.GitServerConfigActivity -import dev.msfjarvis.aps.util.extensions.finish -import dev.msfjarvis.aps.util.extensions.performTransactionWithBackStack -import dev.msfjarvis.aps.util.extensions.sharedPrefs -import dev.msfjarvis.aps.util.extensions.unsafeLazy -import dev.msfjarvis.aps.util.extensions.viewBinding -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat - -class CloneFragment : Fragment(R.layout.fragment_clone) { - - private val binding by viewBinding(FragmentCloneBinding::bind) - - private val settings by unsafeLazy { requireActivity().applicationContext.sharedPrefs } - - private val cloneAction = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == AppCompatActivity.RESULT_OK) { - settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } - finish() - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.cloneRemote.setOnClickListener { cloneToHiddenDir() } - binding.createLocal.setOnClickListener { createRepository() } - } - - /** Clones a remote Git repository to the app's private directory */ - private fun cloneToHiddenDir() { - cloneAction.launch(GitServerConfigActivity.createCloneIntent(requireContext())) - } - - private fun createRepository() { - val localDir = PasswordRepository.getRepositoryDirectory() - runCatching { - check(localDir.exists() || localDir.mkdir()) { "Failed to create directory!" } - PasswordRepository.createRepository(localDir) - if (!PasswordRepository.isInitialized) { - PasswordRepository.initialize() - } - parentFragmentManager.performTransactionWithBackStack(KeySelectionFragment.newInstance()) - } - .onFailure { e -> - logcat(ERROR) { e.asLog() } - if (!localDir.delete()) { - logcat { "Failed to delete local repository: $localDir" } - } - finish() - } - } - - companion object { - - fun newInstance(): CloneFragment = CloneFragment() - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/KeySelectionFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/KeySelectionFragment.kt deleted file mode 100644 index 7783e1eb..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/KeySelectionFragment.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.onboarding.fragments - -import android.content.Intent -import android.os.Bundle -import android.view.View -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import com.google.android.material.snackbar.Snackbar -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.repo.PasswordRepository -import dev.msfjarvis.aps.databinding.FragmentKeySelectionBinding -import dev.msfjarvis.aps.ui.crypto.GetKeyIdsActivity -import dev.msfjarvis.aps.util.extensions.commitChange -import dev.msfjarvis.aps.util.extensions.finish -import dev.msfjarvis.aps.util.extensions.sharedPrefs -import dev.msfjarvis.aps.util.extensions.snackbar -import dev.msfjarvis.aps.util.extensions.unsafeLazy -import dev.msfjarvis.aps.util.extensions.viewBinding -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import java.io.File -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import me.msfjarvis.openpgpktx.util.OpenPgpApi - -class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) { - - private val settings by unsafeLazy { requireActivity().applicationContext.sharedPrefs } - private val binding by viewBinding(FragmentKeySelectionBinding::bind) - - private val gpgKeySelectAction = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == AppCompatActivity.RESULT_OK) { - result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds -> - lifecycleScope.launch { - withContext(Dispatchers.IO) { - val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id") - gpgIdentifierFile.writeText((keyIds + "").joinToString("\n")) - } - settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } - requireActivity() - .commitChange(getString(R.string.git_commit_gpg_id, getString(R.string.app_name))) - } - } - finish() - } else { - requireActivity() - .snackbar( - message = getString(R.string.gpg_key_select_mandatory), - length = Snackbar.LENGTH_LONG - ) - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.selectKey.setOnClickListener { - gpgKeySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java)) - } - } - - companion object { - - fun newInstance() = KeySelectionFragment() - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/WelcomeFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/WelcomeFragment.kt deleted file mode 100644 index ef87741f..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/WelcomeFragment.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.onboarding.fragments - -import android.content.Intent -import android.os.Bundle -import android.view.View -import androidx.annotation.Keep -import androidx.fragment.app.Fragment -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.databinding.FragmentWelcomeBinding -import dev.msfjarvis.aps.ui.settings.SettingsActivity -import dev.msfjarvis.aps.util.extensions.performTransactionWithBackStack -import dev.msfjarvis.aps.util.extensions.viewBinding - -@Keep -class WelcomeFragment : Fragment(R.layout.fragment_welcome) { - - private val binding by viewBinding(FragmentWelcomeBinding::bind) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.letsGo.setOnClickListener { - parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance()) - } - binding.settingsButton.setOnClickListener { - startActivity(Intent(requireContext(), SettingsActivity::class.java)) - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt deleted file mode 100644 index c70f0b5a..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt +++ /dev/null @@ -1,382 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.ui.passwords - -import android.content.Context -import android.content.SharedPreferences -import android.os.Bundle -import android.os.Parcelable -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.animation.Animation -import android.view.animation.AnimationUtils -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.appcompat.view.ActionMode -import androidx.core.content.edit -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import dagger.hilt.android.AndroidEntryPoint -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.password.PasswordItem -import dev.msfjarvis.aps.data.repo.PasswordRepository -import dev.msfjarvis.aps.databinding.PasswordRecyclerViewBinding -import dev.msfjarvis.aps.injection.prefs.SettingsPreferences -import dev.msfjarvis.aps.ui.adapters.PasswordItemRecyclerAdapter -import dev.msfjarvis.aps.ui.dialogs.BasicBottomSheet -import dev.msfjarvis.aps.ui.dialogs.ItemCreationBottomSheet -import dev.msfjarvis.aps.ui.git.base.BaseGitActivity -import dev.msfjarvis.aps.ui.git.config.GitServerConfigActivity -import dev.msfjarvis.aps.ui.util.OnOffItemAnimator -import dev.msfjarvis.aps.util.extensions.base64 -import dev.msfjarvis.aps.util.extensions.getString -import dev.msfjarvis.aps.util.extensions.sharedPrefs -import dev.msfjarvis.aps.util.extensions.viewBinding -import dev.msfjarvis.aps.util.settings.AuthMode -import dev.msfjarvis.aps.util.settings.GitSettings -import dev.msfjarvis.aps.util.settings.PasswordSortOrder -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import dev.msfjarvis.aps.util.shortcuts.ShortcutHandler -import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryViewModel -import java.io.File -import javax.inject.Inject -import kotlinx.coroutines.launch -import me.zhanghai.android.fastscroll.FastScrollerBuilder - -@AndroidEntryPoint -class PasswordFragment : Fragment(R.layout.password_recycler_view) { - - @Inject lateinit var gitSettings: GitSettings - @Inject lateinit var shortcutHandler: ShortcutHandler - @Inject @SettingsPreferences lateinit var prefs: SharedPreferences - private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter - private lateinit var listener: OnFragmentInteractionListener - private lateinit var settings: SharedPreferences - - private var recyclerViewStateToRestore: Parcelable? = null - private var actionMode: ActionMode? = null - private var scrollTarget: File? = null - - private val model: SearchableRepositoryViewModel by activityViewModels() - private val binding by viewBinding(PasswordRecyclerViewBinding::bind) - private val swipeResult = - registerForActivityResult(StartActivityForResult()) { - binding.swipeRefresher.isRefreshing = false - requireStore().refreshPasswordList() - } - - val currentDir: File - get() = model.currentDir.value!! - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - settings = requireContext().sharedPrefs - initializePasswordList() - binding.fab.setOnClickListener { - ItemCreationBottomSheet().show(childFragmentManager, "BOTTOM_SHEET") - } - childFragmentManager.setFragmentResultListener(ITEM_CREATION_REQUEST_KEY, viewLifecycleOwner) { - _, - bundle -> - when (bundle.getString(ACTION_KEY)) { - ACTION_FOLDER -> requireStore().createFolder() - ACTION_PASSWORD -> requireStore().createPassword() - } - } - } - - private fun initializePasswordList() { - val gitDir = File(PasswordRepository.getRepositoryDirectory(), ".git") - val hasGitDir = - gitDir.exists() && gitDir.isDirectory && (gitDir.listFiles()?.isNotEmpty() == true) - binding.swipeRefresher.setOnRefreshListener { - if (!hasGitDir) { - requireStore().refreshPasswordList() - binding.swipeRefresher.isRefreshing = false - } else if (!PasswordRepository.isGitRepo()) { - BasicBottomSheet.Builder(requireContext()) - .setMessageRes(R.string.clone_git_repo) - .setPositiveButtonClickListener(getString(R.string.clone_button)) { - swipeResult.launch(GitServerConfigActivity.createCloneIntent(requireContext())) - } - .build() - .show(requireActivity().supportFragmentManager, "NOT_A_GIT_REPO") - binding.swipeRefresher.isRefreshing = false - } else { - // When authentication is set to AuthMode.None then the only git operation we can - // run is a pull, so automatically fallback to that. - val operationId = - when (gitSettings.authMode) { - AuthMode.None -> BaseGitActivity.GitOp.PULL - else -> BaseGitActivity.GitOp.SYNC - } - requireStore().apply { - lifecycleScope.launch { - launchGitOperation(operationId) - .fold( - success = { - binding.swipeRefresher.isRefreshing = false - refreshPasswordList() - }, - failure = { err -> - promptOnErrorHandler(err) { binding.swipeRefresher.isRefreshing = false } - }, - ) - } - } - } - } - - recyclerAdapter = - PasswordItemRecyclerAdapter(lifecycleScope) - .onItemClicked { _, item -> listener.onFragmentInteraction(item) } - .onSelectionChanged { selection -> - // In order to not interfere with drag selection, we disable the - // SwipeRefreshLayout - // once an item is selected. - binding.swipeRefresher.isEnabled = selection.isEmpty - - if (actionMode == null) - actionMode = - requireStore().startSupportActionMode(actionModeCallback) ?: return@onSelectionChanged - - if (!selection.isEmpty) { - actionMode!!.title = - resources.getQuantityString( - R.plurals.delete_title, - selection.size(), - selection.size() - ) - actionMode!!.invalidate() - } else { - actionMode!!.finish() - } - } - val recyclerView = binding.passRecycler - recyclerView.apply { - addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL)) - layoutManager = LinearLayoutManager(requireContext()) - itemAnimator = OnOffItemAnimator() - adapter = recyclerAdapter - } - - FastScrollerBuilder(recyclerView).build() - recyclerAdapter.makeSelectable(recyclerView) - registerForContextMenu(recyclerView) - - val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) - model.navigateTo(File(path), pushPreviousLocation = false) - model.searchResult.observe(viewLifecycleOwner) { result -> - // Only run animations when the new list is filtered, i.e., the user submitted a search, - // and not on folder navigations since the latter leads to too many removal animations. - (recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered - recyclerAdapter.submitList(result.passwordItems) { - when { - result.isFiltered -> { - // When the result is filtered, we always scroll to the top since that is - // where - // the best fuzzy match appears. - recyclerView.scrollToPosition(0) - } - scrollTarget != null -> { - scrollTarget?.let { - recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it)) - } - scrollTarget = null - } - else -> { - // When the result is not filtered and there is a saved scroll position for - // it, - // we try to restore it. - recyclerViewStateToRestore?.let { - recyclerView.layoutManager!!.onRestoreInstanceState(it) - } - recyclerViewStateToRestore = null - } - } - } - } - } - - private val actionModeCallback = - object : ActionMode.Callback { - // Called when the action mode is created; startActionMode() was called - override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - // Inflate a menu resource providing context menu items - mode.menuInflater.inflate(R.menu.context_pass, menu) - // hide the fab - animateFab(false) - return true - } - - // Called each time the action mode is shown. Always called after onCreateActionMode, - // but may be called multiple times if the mode is invalidated. - override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { - val selectedItems = recyclerAdapter.getSelectedItems() - menu.findItem(R.id.menu_edit_password).isVisible = - selectedItems.all { it.type == PasswordItem.TYPE_CATEGORY } - menu.findItem(R.id.menu_pin_password).isVisible = - selectedItems.size == 1 && selectedItems[0].type == PasswordItem.TYPE_PASSWORD - return true - } - - // Called when the user selects a contextual menu item - override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { - return when (item.itemId) { - R.id.menu_delete_password -> { - requireStore().deletePasswords(recyclerAdapter.getSelectedItems()) - // Action picked, so close the CAB - mode.finish() - true - } - R.id.menu_move_password -> { - requireStore().movePasswords(recyclerAdapter.getSelectedItems()) - false - } - R.id.menu_edit_password -> { - requireStore().renameCategory(recyclerAdapter.getSelectedItems()) - mode.finish() - false - } - R.id.menu_pin_password -> { - val passwordItem = recyclerAdapter.getSelectedItems()[0] - shortcutHandler.addPinnedShortcut( - passwordItem, - passwordItem.createAuthEnabledIntent(requireContext()) - ) - false - } - else -> false - } - } - - // Called when the user exits the action mode - override fun onDestroyActionMode(mode: ActionMode) { - recyclerAdapter.requireSelectionTracker().clearSelection() - actionMode = null - // show the fab - animateFab(true) - } - - private fun animateFab(show: Boolean) = - with(binding.fab) { - val animation = - AnimationUtils.loadAnimation(context, if (show) R.anim.scale_up else R.anim.scale_down) - animation.setAnimationListener( - object : Animation.AnimationListener { - override fun onAnimationRepeat(animation: Animation?) {} - - override fun onAnimationEnd(animation: Animation?) { - if (!show) visibility = View.GONE - } - - override fun onAnimationStart(animation: Animation?) { - if (show) visibility = View.VISIBLE - } - } - ) - animate() - .rotationBy(if (show) -90f else 90f) - .setStartDelay(if (show) 100 else 0) - .setDuration(100) - .start() - startAnimation(animation) - } - } - - override fun onResume() { - super.onResume() - binding.swipeRefresher.isEnabled = !prefs.getBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, false) - } - - override fun onAttach(context: Context) { - super.onAttach(context) - runCatching { - listener = - object : OnFragmentInteractionListener { - override fun onFragmentInteraction(item: PasswordItem) { - if ( - settings.getString(PreferenceKeys.SORT_ORDER) == - PasswordSortOrder.RECENTLY_USED.name - ) { - // save the time when password was used - val preferences = - context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) - preferences.edit { - putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString()) - } - } - - if (item.type == PasswordItem.TYPE_CATEGORY) { - navigateTo(item.file) - } else { - if (requireArguments().getBoolean("matchWith", false)) { - requireStore().matchPasswordWithApp(item) - } else { - requireStore().decryptPassword(item) - } - } - } - } - } - .onFailure { - throw ClassCastException("$context must implement OnFragmentInteractionListener") - } - } - - private fun requireStore() = requireActivity() as PasswordStore - - /** Returns true if the back press was handled by the [Fragment]. */ - fun onBackPressedInActivity(): Boolean { - if (!model.canNavigateBack) return false - // The RecyclerView state is restored when the asynchronous update operation on the - // adapter is completed. - recyclerViewStateToRestore = model.navigateBack() - if (!model.canNavigateBack) requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(false) - return true - } - - fun dismissActionMode() { - actionMode?.finish() - } - - companion object { - - const val ITEM_CREATION_REQUEST_KEY = "creation_key" - const val ACTION_KEY = "action" - const val ACTION_FOLDER = "folder" - const val ACTION_PASSWORD = "password" - - fun newInstance(args: Bundle): PasswordFragment { - val fragment = PasswordFragment() - fragment.arguments = args - return fragment - } - } - - fun navigateTo(file: File) { - requireStore().clearSearch() - model.navigateTo( - file, - recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState() - ) - requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true) - } - - fun scrollToOnNextRefresh(file: File) { - scrollTarget = file - } - - interface OnFragmentInteractionListener { - - fun onFragmentInteraction(item: PasswordItem) - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt deleted file mode 100644 index 0ee42896..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt +++ /dev/null @@ -1,639 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.ui.passwords - -import android.annotation.SuppressLint -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.KeyEvent -import android.view.Menu -import android.view.MenuItem -import android.view.MenuItem.OnActionExpandListener -import android.view.WindowManager -import androidx.activity.result.contract.ActivityResultContracts.RequestPermission -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.activity.viewModels -import androidx.appcompat.widget.SearchView -import androidx.appcompat.widget.SearchView.OnQueryTextListener -import androidx.core.content.edit -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.commit -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.lifecycleScope -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.TextInputEditText -import dagger.hilt.android.AndroidEntryPoint -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.password.PasswordItem -import dev.msfjarvis.aps.data.repo.PasswordRepository -import dev.msfjarvis.aps.ui.crypto.BasePgpActivity -import dev.msfjarvis.aps.ui.crypto.BasePgpActivity.Companion.getLongName -import dev.msfjarvis.aps.ui.crypto.DecryptActivity -import dev.msfjarvis.aps.ui.crypto.DecryptActivityV2 -import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivity -import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivityV2 -import dev.msfjarvis.aps.ui.dialogs.FolderCreationDialogFragment -import dev.msfjarvis.aps.ui.folderselect.SelectFolderActivity -import dev.msfjarvis.aps.ui.git.base.BaseGitActivity -import dev.msfjarvis.aps.ui.onboarding.activity.OnboardingActivity -import dev.msfjarvis.aps.ui.settings.SettingsActivity -import dev.msfjarvis.aps.util.autofill.AutofillMatcher -import dev.msfjarvis.aps.util.extensions.base64 -import dev.msfjarvis.aps.util.extensions.commitChange -import dev.msfjarvis.aps.util.extensions.contains -import dev.msfjarvis.aps.util.extensions.getString -import dev.msfjarvis.aps.util.extensions.isInsideRepository -import dev.msfjarvis.aps.util.extensions.listFilesRecursively -import dev.msfjarvis.aps.util.extensions.sharedPrefs -import dev.msfjarvis.aps.util.features.Feature -import dev.msfjarvis.aps.util.features.Features -import dev.msfjarvis.aps.util.settings.AuthMode -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import dev.msfjarvis.aps.util.shortcuts.ShortcutHandler -import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryViewModel -import java.io.File -import java.lang.Character.UnicodeBlock -import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.LogPriority.ERROR -import logcat.LogPriority.INFO -import logcat.logcat - -const val PASSWORD_FRAGMENT_TAG = "PasswordsList" - -@AndroidEntryPoint -class PasswordStore : BaseGitActivity() { - - @Inject lateinit var features: Features - @Inject lateinit var shortcutHandler: ShortcutHandler - private lateinit var searchItem: MenuItem - private val settings by lazy { sharedPrefs } - - private val model: SearchableRepositoryViewModel by viewModels { - ViewModelProvider.AndroidViewModelFactory(application) - } - - private val storagePermissionRequest = - registerForActivityResult(RequestPermission()) { granted -> - if (granted) checkLocalRepository() - } - - private val directorySelectAction = - registerForActivityResult(StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - checkLocalRepository() - } - } - - private val listRefreshAction = - registerForActivityResult(StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - refreshPasswordList() - } - } - - private val passwordMoveAction = - registerForActivityResult(StartActivityForResult()) { result -> - val intentData = result.data ?: return@registerForActivityResult - val filesToMove = requireNotNull(intentData.getStringArrayExtra("Files")) - val target = File(requireNotNull(intentData.getStringExtra("SELECTED_FOLDER_PATH"))) - val repositoryPath = PasswordRepository.getRepositoryDirectory().absolutePath - if (!target.isDirectory) { - logcat(ERROR) { "Tried moving passwords to a non-existing folder." } - return@registerForActivityResult - } - - logcat { "Moving passwords to ${intentData.getStringExtra("SELECTED_FOLDER_PATH")}" } - logcat { filesToMove.joinToString(", ") } - - lifecycleScope.launch(Dispatchers.IO) { - for (file in filesToMove) { - val source = File(file) - if (!source.exists()) { - logcat(ERROR) { "Tried moving something that appears non-existent." } - continue - } - val destinationFile = File(target.absolutePath + "/" + source.name) - val basename = source.nameWithoutExtension - val sourceLongName = getLongName(requireNotNull(source.parent), repositoryPath, basename) - val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename) - if (destinationFile.exists()) { - logcat(ERROR) { "Trying to move a file that already exists." } - withContext(Dispatchers.Main) { - MaterialAlertDialogBuilder(this@PasswordStore) - .setTitle(resources.getString(R.string.password_exists_title)) - .setMessage( - resources.getString( - R.string.password_exists_message, - destinationLongName, - sourceLongName - ) - ) - .setPositiveButton(R.string.dialog_ok) { _, _ -> - launch(Dispatchers.IO) { moveFile(source, destinationFile) } - } - .setNegativeButton(R.string.dialog_cancel, null) - .show() - } - } else { - launch(Dispatchers.IO) { moveFile(source, destinationFile) } - } - } - when (filesToMove.size) { - 1 -> { - val source = File(filesToMove[0]) - val basename = source.nameWithoutExtension - val sourceLongName = - getLongName(requireNotNull(source.parent), repositoryPath, basename) - val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename) - withContext(Dispatchers.Main) { - commitChange( - resources.getString( - R.string.git_commit_move_text, - sourceLongName, - destinationLongName - ), - ) - } - } - else -> { - val repoDir = PasswordRepository.getRepositoryDirectory().absolutePath - val relativePath = getRelativePath("${target.absolutePath}/", repoDir) - withContext(Dispatchers.Main) { - commitChange( - resources.getString(R.string.git_commit_move_multiple_text, relativePath), - ) - } - } - } - } - refreshPasswordList() - getPasswordFragment()?.dismissActionMode() - } - - override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { - // open search view on search key, or Ctr+F - if ( - (keyCode == KeyEvent.KEYCODE_SEARCH || - keyCode == KeyEvent.KEYCODE_F && event.isCtrlPressed) && !searchItem.isActionViewExpanded - ) { - searchItem.expandActionView() - return true - } - - // open search view on any printable character and query for it - val c = event.unicodeChar.toChar() - val printable = isPrintable(c) - if (printable && !searchItem.isActionViewExpanded) { - searchItem.expandActionView() - (searchItem.actionView as SearchView).setQuery(c.toString(), true) - return true - } - return super.onKeyDown(keyCode, event) - } - - @SuppressLint("NewApi") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_pwdstore) - - model.currentDir.observe(this) { dir -> - val basePath = PasswordRepository.getRepositoryDirectory().absoluteFile - supportActionBar?.apply { - if (dir != basePath) title = dir.name else setTitle(R.string.app_name) - } - } - } - - override fun onStart() { - super.onStart() - refreshPasswordList() - } - - override fun onResume() { - super.onResume() - checkLocalRepository() - if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false) && ::searchItem.isInitialized) { - if (!searchItem.isActionViewExpanded) { - searchItem.expandActionView() - } - } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - val menuRes = - when { - gitSettings.authMode == AuthMode.None -> R.menu.main_menu_no_auth - PasswordRepository.isGitRepo() -> R.menu.main_menu_git - else -> R.menu.main_menu_non_git - } - menuInflater.inflate(menuRes, menu) - return super.onCreateOptionsMenu(menu) - } - - override fun onPrepareOptionsMenu(menu: Menu): Boolean { - // Invalidation forces onCreateOptionsMenu to be called again. This is cheap and quick so - // we can get by without any noticeable difference in performance. - invalidateOptionsMenu() - searchItem = menu.findItem(R.id.action_search) - val searchView = searchItem.actionView as SearchView - searchView.setOnQueryTextListener( - object : OnQueryTextListener { - override fun onQueryTextSubmit(s: String): Boolean { - searchView.clearFocus() - return true - } - - override fun onQueryTextChange(s: String): Boolean { - val filter = s.trim() - // List the contents of the current directory if the user enters a blank - // search term. - if (filter.isEmpty()) - model.navigateTo(newDirectory = model.currentDir.value!!, pushPreviousLocation = false) - else model.search(filter) - return true - } - } - ) - - // When using the support library, the setOnActionExpandListener() method is - // static and accepts the MenuItem object as an argument - searchItem.setOnActionExpandListener( - object : OnActionExpandListener { - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - refreshPasswordList() - return true - } - - override fun onMenuItemActionExpand(item: MenuItem): Boolean { - return true - } - } - ) - if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false)) { - searchItem.expandActionView() - } - return super.onPrepareOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - val id = item.itemId - val initBefore = - MaterialAlertDialogBuilder(this) - .setMessage(resources.getString(R.string.creation_dialog_text)) - .setPositiveButton(resources.getString(R.string.dialog_ok), null) - when (id) { - R.id.user_pref -> { - runCatching { startActivity(Intent(this, SettingsActivity::class.java)) } - .onFailure { e -> e.printStackTrace() } - } - R.id.git_push -> { - if (!PasswordRepository.isInitialized) { - initBefore.show() - } else { - runGitOperation(GitOp.PUSH) - } - } - R.id.git_pull -> { - if (!PasswordRepository.isInitialized) { - initBefore.show() - } else { - runGitOperation(GitOp.PULL) - } - } - R.id.git_sync -> { - if (!PasswordRepository.isInitialized) { - initBefore.show() - } else { - runGitOperation(GitOp.SYNC) - } - } - R.id.refresh -> refreshPasswordList() - android.R.id.home -> onBackPressed() - else -> return super.onOptionsItemSelected(item) - } - return true - } - - override fun onBackPressed() { - if (getPasswordFragment()?.onBackPressedInActivity() != true) super.onBackPressed() - } - - private fun getPasswordFragment(): PasswordFragment? { - return supportFragmentManager.findFragmentByTag(PASSWORD_FRAGMENT_TAG) as? PasswordFragment - } - - fun clearSearch() { - if (searchItem.isActionViewExpanded) searchItem.collapseActionView() - } - - private fun runGitOperation(operation: GitOp) = - lifecycleScope.launch { - launchGitOperation(operation) - .fold( - success = { refreshPasswordList() }, - failure = { promptOnErrorHandler(it) }, - ) - } - - private fun checkLocalRepository() { - PasswordRepository.initialize() - checkLocalRepository(PasswordRepository.getRepositoryDirectory()) - } - - private fun checkLocalRepository(localDir: File?) { - if (localDir != null && settings.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) { - logcat { "Check, dir: ${localDir.absolutePath}" } - // do not push the fragment if we already have it - if ( - getPasswordFragment() == null || settings.getBoolean(PreferenceKeys.REPO_CHANGED, false) - ) { - settings.edit { putBoolean(PreferenceKeys.REPO_CHANGED, false) } - val args = Bundle() - args.putString(REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath) - - // if the activity was started from the autofill settings, the - // intent is to match a clicked pwd with app. pass this to fragment - if (intent.getBooleanExtra("matchWith", false)) { - args.putBoolean("matchWith", true) - } - supportActionBar?.apply { - show() - setDisplayHomeAsUpEnabled(false) - } - supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) - supportFragmentManager.commit { - replace(R.id.main_layout, PasswordFragment.newInstance(args), PASSWORD_FRAGMENT_TAG) - } - } - } else { - startActivity(Intent(this, OnboardingActivity::class.java)) - } - } - - private fun getRelativePath(fullPath: String, repositoryPath: String): String { - return fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") - } - - fun decryptPassword(item: PasswordItem) { - val authDecryptIntent = item.createAuthEnabledIntent(this) - val decryptIntent = - (authDecryptIntent.clone() as Intent).setComponent( - ComponentName( - this, - if (features.isEnabled(Feature.EnablePGPainlessBackend)) { - DecryptActivityV2::class.java - } else { - DecryptActivity::class.java - } - ) - ) - - startActivity(decryptIntent) - - // Adds shortcut - shortcutHandler.addDynamicShortcut(item, authDecryptIntent) - } - - private fun validateState(): Boolean { - if (!PasswordRepository.isInitialized) { - MaterialAlertDialogBuilder(this) - .setMessage(resources.getString(R.string.creation_dialog_text)) - .setPositiveButton(resources.getString(R.string.dialog_ok), null) - .show() - return false - } - return true - } - - fun createPassword() { - if (!validateState()) return - val currentDir = currentDir - logcat(INFO) { "Adding file to : ${currentDir.absolutePath}" } - val creationActivity = - if (features.isEnabled(Feature.EnablePGPainlessBackend)) - PasswordCreationActivityV2::class.java - else PasswordCreationActivity::class.java - val intent = Intent(this, creationActivity) - intent.putExtra(BasePgpActivity.EXTRA_FILE_PATH, currentDir.absolutePath) - intent.putExtra( - BasePgpActivity.EXTRA_REPO_PATH, - PasswordRepository.getRepositoryDirectory().absolutePath - ) - listRefreshAction.launch(intent) - } - - fun createFolder() { - if (!validateState()) return - FolderCreationDialogFragment.newInstance(currentDir.path).show(supportFragmentManager, null) - } - - fun deletePasswords(selectedItems: List) { - var size = 0 - selectedItems.forEach { - if (it.file.isFile) size++ else size += it.file.listFilesRecursively().size - } - if (size == 0) { - selectedItems.map { item -> item.file.deleteRecursively() } - refreshPasswordList() - return - } - MaterialAlertDialogBuilder(this) - .setMessage(resources.getQuantityString(R.plurals.delete_dialog_text, size, size)) - .setPositiveButton(resources.getString(R.string.dialog_yes)) { _, _ -> - val filesToDelete = arrayListOf() - selectedItems.forEach { item -> - if (item.file.isDirectory) filesToDelete.addAll(item.file.listFilesRecursively()) - else filesToDelete.add(item.file) - } - selectedItems.map { item -> item.file.deleteRecursively() } - refreshPasswordList() - AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete) - val fmt = - selectedItems.joinToString(separator = ", ") { item -> - item.file.toRelativeString(PasswordRepository.getRepositoryDirectory()) - } - lifecycleScope.launch { - commitChange( - resources.getString(R.string.git_commit_remove_text, fmt), - ) - } - } - .setNegativeButton(resources.getString(R.string.dialog_no), null) - .show() - } - - fun movePasswords(values: List) { - val intent = Intent(this, SelectFolderActivity::class.java) - val fileLocations = values.map { it.file.absolutePath }.toTypedArray() - intent.putExtra("Files", fileLocations) - passwordMoveAction.launch(intent) - } - - enum class CategoryRenameError(val resource: Int) { - None(0), - EmptyField(R.string.message_category_error_empty_field), - CategoryExists(R.string.message_category_error_category_exists), - DestinationOutsideRepo(R.string.message_error_destination_outside_repo), - } - - /** - * Prompt the user with a new category name to assign, if the new category forms/leads a path - * (i.e. contains "/"), intermediate directories will be created and new category will be placed - * inside. - * - * @param oldCategory The category to change its name - * @param error Determines whether to show an error to the user in the alert dialog, this error - * may be due to the new category the user entered already exists or the field was empty or the - * destination path is outside the repository - * - * @see [CategoryRenameError] - * @see [isInsideRepository] - */ - private fun renameCategory( - oldCategory: PasswordItem, - error: CategoryRenameError = CategoryRenameError.None - ) { - val view = layoutInflater.inflate(R.layout.folder_dialog_fragment, null) - val newCategoryEditText = view.findViewById(R.id.folder_name_text) - - if (error != CategoryRenameError.None) { - newCategoryEditText.error = getString(error.resource) - } - - val dialog = - MaterialAlertDialogBuilder(this) - .setTitle(R.string.title_rename_folder) - .setView(view) - .setMessage(getString(R.string.message_rename_folder, oldCategory.name)) - .setPositiveButton(R.string.dialog_ok) { _, _ -> - val newCategory = File("${oldCategory.file.parent}/${newCategoryEditText.text}") - when { - newCategoryEditText.text.isNullOrBlank() -> - renameCategory(oldCategory, CategoryRenameError.EmptyField) - newCategory.exists() -> renameCategory(oldCategory, CategoryRenameError.CategoryExists) - !newCategory.isInsideRepository() -> - renameCategory(oldCategory, CategoryRenameError.DestinationOutsideRepo) - else -> - lifecycleScope.launch(Dispatchers.IO) { - moveFile(oldCategory.file, newCategory) - - // associate the new category with the last category's timestamp in - // history - val preference = - getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) - val timestamp = preference.getString(oldCategory.file.absolutePath.base64()) - if (timestamp != null) { - preference.edit { - remove(oldCategory.file.absolutePath.base64()) - putString(newCategory.absolutePath.base64(), timestamp) - } - } - - withContext(Dispatchers.Main) { - commitChange( - resources.getString( - R.string.git_commit_move_text, - oldCategory.name, - newCategory.name - ), - ) - } - } - } - } - .setNegativeButton(R.string.dialog_skip, null) - .create() - - dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - dialog.show() - } - - fun renameCategory(categories: List) { - for (oldCategory in categories) { - renameCategory(oldCategory) - } - } - - /** - * Refreshes the password list by re-executing the last navigation or search action, preserving - * the navigation stack and scroll position. If the current directory no longer exists, navigation - * is reset to the repository root. If the optional [target] argument is provided, it will be - * entered if it is a directory or scrolled into view if it is a file (both inside the current - * directory). - */ - fun refreshPasswordList(target: File? = null) { - val plist = getPasswordFragment() - if (target?.isDirectory == true && model.currentDir.value?.contains(target) == true) { - plist?.navigateTo(target) - } else if (target?.isFile == true && model.currentDir.value?.contains(target) == true) { - // Creating new passwords is handled by an activity, so we will refresh in onStart. - plist?.scrollToOnNextRefresh(target) - } else if (model.currentDir.value?.isDirectory == true) { - model.forceRefresh() - } else { - model.reset() - supportActionBar?.setDisplayHomeAsUpEnabled(false) - } - } - - private val currentDir: File - get() = getPasswordFragment()?.currentDir ?: PasswordRepository.getRepositoryDirectory() - - private suspend fun moveFile(source: File, destinationFile: File) { - val sourceDestinationMap = - if (source.isDirectory) { - destinationFile.mkdirs() - // Recursively list all files (not directories) below `source`, then - // obtain the corresponding target file by resolving the relative path - // starting at the destination folder. - source.listFilesRecursively().associateWith { - destinationFile.resolve(it.relativeTo(source)) - } - } else { - mapOf(source to destinationFile) - } - if (!source.renameTo(destinationFile)) { - logcat(ERROR) { "Something went wrong while moving $source to $destinationFile." } - withContext(Dispatchers.Main) { - MaterialAlertDialogBuilder(this@PasswordStore) - .setTitle(R.string.password_move_error_title) - .setMessage(getString(R.string.password_move_error_message, source, destinationFile)) - .setCancelable(true) - .setPositiveButton(android.R.string.ok, null) - .show() - } - } else { - AutofillMatcher.updateMatches(this, sourceDestinationMap) - } - } - - fun matchPasswordWithApp(item: PasswordItem) { - val path = - item.file.absolutePath - .replace(PasswordRepository.getRepositoryDirectory().toString() + "/", "") - .replace(".gpg", "") - val data = Intent() - data.putExtra("path", path) - setResult(RESULT_OK, data) - finish() - } - - companion object { - - const val REQUEST_ARG_PATH = "PATH" - private fun isPrintable(c: Char): Boolean { - val block = UnicodeBlock.of(c) - return (!Character.isISOControl(c) && block != null && block !== UnicodeBlock.SPECIALS) - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/pgp/PGPKeyImportActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/pgp/PGPKeyImportActivity.kt deleted file mode 100644 index e140ac91..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/pgp/PGPKeyImportActivity.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -@file:Suppress("BlockingMethodInNonBlockingContext") - -package dev.msfjarvis.aps.ui.pgp - -import android.os.Bundle -import androidx.activity.result.contract.ActivityResultContracts.OpenDocument -import androidx.appcompat.app.AppCompatActivity -import com.github.michaelbull.result.mapBoth -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.crypto.KeyUtils.tryGetId -import dev.msfjarvis.aps.crypto.PGPKey -import dev.msfjarvis.aps.crypto.PGPKeyManager -import javax.inject.Inject -import kotlinx.coroutines.runBlocking - -@AndroidEntryPoint -class PGPKeyImportActivity : AppCompatActivity() { - - @Inject lateinit var keyManager: PGPKeyManager - - private val pgpKeyImportAction = - registerForActivityResult(OpenDocument()) { uri -> - runCatching { - if (uri == null) { - return@runCatching null - } - val keyInputStream = - contentResolver.openInputStream(uri) - ?: throw IllegalStateException("Failed to open selected file") - val bytes = keyInputStream.readBytes() - val (key, error) = runBlocking { keyManager.addKey(PGPKey(bytes)) } - if (error != null) throw error - key - } - .mapBoth( - { key -> - if (key != null) { - MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.pgp_key_import_succeeded)) - .setMessage(getString(R.string.pgp_key_import_succeeded_message, tryGetId(key))) - .setPositiveButton(android.R.string.ok) { _, _ -> finish() } - .setOnCancelListener { finish() } - .show() - } else { - finish() - } - }, - { throwable -> - MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.pgp_key_import_failed)) - .setMessage(throwable.message) - .setPositiveButton(android.R.string.ok) { _, _ -> finish() } - .setOnCancelListener { finish() } - .show() - } - ) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - pgpKeyImportAction.launch(arrayOf("*/*")) - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/proxy/ProxySelectorActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/proxy/ProxySelectorActivity.kt deleted file mode 100644 index 41cab040..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/proxy/ProxySelectorActivity.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.proxy - -import android.content.SharedPreferences -import android.net.InetAddresses -import android.os.Build -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.util.Patterns -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.core.os.postDelayed -import androidx.core.widget.doOnTextChanged -import dagger.hilt.android.AndroidEntryPoint -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.databinding.ActivityProxySelectorBinding -import dev.msfjarvis.aps.injection.prefs.ProxyPreferences -import dev.msfjarvis.aps.util.extensions.getString -import dev.msfjarvis.aps.util.extensions.viewBinding -import dev.msfjarvis.aps.util.proxy.ProxyUtils -import dev.msfjarvis.aps.util.settings.GitSettings -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import javax.inject.Inject - -private val WEB_ADDRESS_REGEX = Patterns.WEB_URL.toRegex() - -@AndroidEntryPoint -class ProxySelectorActivity : AppCompatActivity() { - - @Inject lateinit var gitSettings: GitSettings - @ProxyPreferences @Inject lateinit var proxyPrefs: SharedPreferences - @Inject lateinit var proxyUtils: ProxyUtils - private val binding by viewBinding(ActivityProxySelectorBinding::inflate) - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - with(binding) { - proxyHost.setText(proxyPrefs.getString(PreferenceKeys.PROXY_HOST)) - proxyUser.setText(proxyPrefs.getString(PreferenceKeys.PROXY_USERNAME)) - proxyPrefs - .getInt(PreferenceKeys.PROXY_PORT, -1) - .takeIf { it != -1 } - ?.let { proxyPort.setText("$it") } - proxyPassword.setText(proxyPrefs.getString(PreferenceKeys.PROXY_PASSWORD)) - save.setOnClickListener { saveSettings() } - proxyHost.doOnTextChanged { text, _, _, _ -> - if (text != null) { - proxyHost.error = - if (isNumericAddress(text) || text.matches(WEB_ADDRESS_REGEX)) { - null - } else { - getString(R.string.invalid_proxy_url) - } - } - } - } - } - - private fun isNumericAddress(text: CharSequence): Boolean { - return if (Build.VERSION.SDK_INT >= 29) { - InetAddresses.isNumericAddress(text as String) - } else { - @Suppress("DEPRECATION") Patterns.IP_ADDRESS.matcher(text).matches() - } - } - - private fun saveSettings() { - proxyPrefs.edit { - binding.proxyHost.text - ?.toString() - ?.takeIf { it.isNotEmpty() } - .let { gitSettings.proxyHost = it } - binding.proxyUser.text - ?.toString() - ?.takeIf { it.isNotEmpty() } - .let { gitSettings.proxyUsername = it } - binding.proxyPort.text - ?.toString() - ?.takeIf { it.isNotEmpty() } - ?.let { gitSettings.proxyPort = it.toInt() } - binding.proxyPassword.text - ?.toString() - ?.takeIf { it.isNotEmpty() } - .let { gitSettings.proxyPassword = it } - } - proxyUtils.setDefaultProxy() - Handler(Looper.getMainLooper()).postDelayed(500) { finish() } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/AutofillSettings.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/AutofillSettings.kt deleted file mode 100644 index 80dce577..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/AutofillSettings.kt +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.settings - -import android.annotation.SuppressLint -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.provider.Settings -import androidx.annotation.RequiresApi -import androidx.appcompat.widget.AppCompatTextView -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel -import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import de.Maxr1998.modernpreferences.PreferenceScreen -import de.Maxr1998.modernpreferences.helpers.editText -import de.Maxr1998.modernpreferences.helpers.onClick -import de.Maxr1998.modernpreferences.helpers.singleChoice -import de.Maxr1998.modernpreferences.helpers.switch -import de.Maxr1998.modernpreferences.preferences.SwitchPreference -import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem -import dev.msfjarvis.aps.BuildConfig -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.util.autofill.DirectoryStructure -import dev.msfjarvis.aps.util.extensions.autofillManager -import dev.msfjarvis.aps.util.settings.PreferenceKeys - -class AutofillSettings(private val activity: FragmentActivity) : SettingsProvider { - - private val isAutofillServiceEnabled: Boolean - get() { - if (Build.VERSION.SDK_INT < 26) return false - return activity.autofillManager?.hasEnabledAutofillServices() == true - } - - @RequiresApi(26) - private fun showAutofillDialog(pref: SwitchPreference) { - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> { - pref.checked = isAutofillServiceEnabled - } - else -> {} - } - } - MaterialAlertDialogBuilder(activity).run { - setTitle(R.string.pref_autofill_enable_title) - @SuppressLint("InflateParams") - val layout = activity.layoutInflater.inflate(R.layout.oreo_autofill_instructions, null) - val supportedBrowsersTextView = layout.findViewById(R.id.supportedBrowsers) - supportedBrowsersTextView.text = - getInstalledBrowsersWithAutofillSupportLevel(context).joinToString(separator = "\n") { - val appLabel = it.first - val supportDescription = - when (it.second) { - BrowserAutofillSupportLevel.None -> - activity.getString(R.string.oreo_autofill_no_support) - BrowserAutofillSupportLevel.FlakyFill -> - activity.getString(R.string.oreo_autofill_flaky_fill_support) - BrowserAutofillSupportLevel.PasswordFill -> - activity.getString(R.string.oreo_autofill_password_fill_support) - BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility -> - activity.getString( - R.string.oreo_autofill_password_fill_and_conditional_save_support - ) - BrowserAutofillSupportLevel.GeneralFill -> - activity.getString(R.string.oreo_autofill_general_fill_support) - BrowserAutofillSupportLevel.GeneralFillAndSave -> - activity.getString(R.string.oreo_autofill_general_fill_and_save_support) - } - "$appLabel: $supportDescription" - } - setView(layout) - setPositiveButton(R.string.dialog_ok) { _, _ -> - val intent = - Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply { - data = Uri.parse("package:${BuildConfig.APPLICATION_ID}") - } - activity.startActivity(intent) - } - setNegativeButton(R.string.dialog_cancel, null) - setOnDismissListener { pref.checked = isAutofillServiceEnabled } - activity.lifecycle.addObserver(observer) - show() - } - } - - override fun provideSettings(builder: PreferenceScreen.Builder) { - builder.apply { - switch(PreferenceKeys.AUTOFILL_ENABLE) { - titleRes = R.string.pref_autofill_enable_title - visible = Build.VERSION.SDK_INT >= 26 - defaultValue = isAutofillServiceEnabled - onClick { - if (Build.VERSION.SDK_INT < 26) return@onClick true - if (isAutofillServiceEnabled) { - activity.autofillManager?.disableAutofillServices() - } else { - showAutofillDialog(this) - } - false - } - } - val values = - activity.resources.getStringArray(R.array.oreo_autofill_directory_structure_values) - val titles = - activity.resources.getStringArray(R.array.oreo_autofill_directory_structure_entries) - val items = values.zip(titles).map { SelectionItem(it.first, it.second, null) } - singleChoice(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE, items) { - initialSelection = DirectoryStructure.DEFAULT.value - dependency = PreferenceKeys.AUTOFILL_ENABLE - titleRes = R.string.oreo_autofill_preference_directory_structure - } - editText(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) { - dependency = PreferenceKeys.AUTOFILL_ENABLE - titleRes = R.string.preference_default_username_title - summaryProvider = { activity.getString(R.string.preference_default_username_summary) } - } - editText(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) { - dependency = PreferenceKeys.AUTOFILL_ENABLE - titleRes = R.string.preference_custom_public_suffixes_title - summaryProvider = { activity.getString(R.string.preference_custom_public_suffixes_summary) } - textInputHintRes = R.string.preference_custom_public_suffixes_hint - } - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/GeneralSettings.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/GeneralSettings.kt deleted file mode 100644 index 21ecd55c..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/GeneralSettings.kt +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.settings - -import android.content.pm.ShortcutManager -import android.os.Build -import androidx.core.content.edit -import androidx.core.content.getSystemService -import androidx.fragment.app.FragmentActivity -import de.Maxr1998.modernpreferences.PreferenceScreen -import de.Maxr1998.modernpreferences.helpers.checkBox -import de.Maxr1998.modernpreferences.helpers.onClick -import de.Maxr1998.modernpreferences.helpers.singleChoice -import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.util.auth.BiometricAuthenticator -import dev.msfjarvis.aps.util.auth.BiometricAuthenticator.Result -import dev.msfjarvis.aps.util.extensions.sharedPrefs -import dev.msfjarvis.aps.util.settings.PreferenceKeys - -class GeneralSettings(private val activity: FragmentActivity) : SettingsProvider { - - override fun provideSettings(builder: PreferenceScreen.Builder) { - builder.apply { - val themeValues = activity.resources.getStringArray(R.array.app_theme_values) - val themeOptions = activity.resources.getStringArray(R.array.app_theme_options) - val themeItems = - themeValues.zip(themeOptions).map { SelectionItem(it.first, it.second, null) } - singleChoice(PreferenceKeys.APP_THEME, themeItems) { - initialSelection = activity.resources.getString(R.string.app_theme_def) - titleRes = R.string.pref_app_theme_title - } - - val sortValues = activity.resources.getStringArray(R.array.sort_order_values) - val sortOptions = activity.resources.getStringArray(R.array.sort_order_entries) - val sortItems = sortValues.zip(sortOptions).map { SelectionItem(it.first, it.second, null) } - singleChoice(PreferenceKeys.SORT_ORDER, sortItems) { - initialSelection = sortValues[0] - titleRes = R.string.pref_sort_order_title - } - - checkBox(PreferenceKeys.DISABLE_SYNC_ACTION) { - titleRes = R.string.pref_disable_sync_on_pull_title - summaryRes = R.string.pref_disable_sync_on_pull_summary - defaultValue = false - } - - checkBox(PreferenceKeys.FILTER_RECURSIVELY) { - titleRes = R.string.pref_recursive_filter_title - summaryRes = R.string.pref_recursive_filter_summary - defaultValue = true - } - - checkBox(PreferenceKeys.SEARCH_ON_START) { - titleRes = R.string.pref_search_on_start_title - summaryRes = R.string.pref_search_on_start_summary - defaultValue = false - } - - checkBox(PreferenceKeys.SHOW_HIDDEN_CONTENTS) { - titleRes = R.string.pref_show_hidden_title - summaryRes = R.string.pref_show_hidden_summary - defaultValue = false - } - - val canAuthenticate = BiometricAuthenticator.canAuthenticate(activity) - checkBox(PreferenceKeys.BIOMETRIC_AUTH) { - titleRes = R.string.pref_biometric_auth_title - defaultValue = false - enabled = canAuthenticate - summaryRes = - if (canAuthenticate) R.string.pref_biometric_auth_summary - else R.string.pref_biometric_auth_summary_error - onClick { - enabled = false - val isChecked = checked - activity.sharedPrefs.edit { - BiometricAuthenticator.authenticate(activity) { result -> - when (result) { - is Result.Success -> { - // Apply the changes - putBoolean(PreferenceKeys.BIOMETRIC_AUTH, checked) - enabled = true - } - is Result.Retry -> {} - else -> { - // If any error occurs, revert back to the previous - // state. This - // catch-all clause includes the cancellation case. - putBoolean(PreferenceKeys.BIOMETRIC_AUTH, !checked) - checked = !isChecked - enabled = true - } - } - } - } - if (Build.VERSION.SDK_INT >= 25) { - activity.getSystemService()?.apply { - removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList()) - } - } - false - } - } - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/MiscSettings.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/MiscSettings.kt deleted file mode 100644 index 5bb50cd8..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/MiscSettings.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.settings - -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Build -import androidx.activity.result.contract.ActivityResultContracts -import androidx.documentfile.provider.DocumentFile -import androidx.fragment.app.FragmentActivity -import de.Maxr1998.modernpreferences.PreferenceScreen -import de.Maxr1998.modernpreferences.helpers.checkBox -import de.Maxr1998.modernpreferences.helpers.onClick -import de.Maxr1998.modernpreferences.helpers.pref -import dev.msfjarvis.aps.BuildConfig -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.util.services.PasswordExportService -import dev.msfjarvis.aps.util.settings.PreferenceKeys - -class MiscSettings(activity: FragmentActivity) : SettingsProvider { - - private val storeExportAction = - activity.registerForActivityResult( - object : ActivityResultContracts.OpenDocumentTree() { - override fun createIntent(context: Context, input: Uri?): Intent { - return super.createIntent(context, input).apply { - flags = - Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION or - Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or - Intent.FLAG_GRANT_PREFIX_URI_PERMISSION - } - } - } - ) { uri: Uri? -> - if (uri == null) return@registerForActivityResult - val targetDirectory = DocumentFile.fromTreeUri(activity.applicationContext, uri) - - if (targetDirectory != null) { - val service = - Intent(activity.applicationContext, PasswordExportService::class.java).apply { - action = PasswordExportService.ACTION_EXPORT_PASSWORD - putExtra("uri", uri) - } - - if (Build.VERSION.SDK_INT >= 26) { - activity.startForegroundService(service) - } else { - activity.startService(service) - } - } - } - - override fun provideSettings(builder: PreferenceScreen.Builder) { - builder.apply { - pref(PreferenceKeys.EXPORT_PASSWORDS) { - titleRes = R.string.prefs_export_passwords_title - summaryRes = R.string.prefs_export_passwords_summary - onClick { - storeExportAction.launch(null) - true - } - } - checkBox(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY) { - defaultValue = false - titleRes = R.string.pref_clear_clipboard_title - summaryRes = R.string.pref_clear_clipboard_summary - } - checkBox(PreferenceKeys.ENABLE_DEBUG_LOGGING) { - defaultValue = false - titleRes = R.string.pref_debug_logging_title - summaryRes = R.string.pref_debug_logging_summary - visible = !BuildConfig.DEBUG - } - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/PGPSettings.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/PGPSettings.kt deleted file mode 100644 index b81af006..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/PGPSettings.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.settings - -import androidx.fragment.app.FragmentActivity -import de.Maxr1998.modernpreferences.PreferenceScreen -import de.Maxr1998.modernpreferences.helpers.checkBox -import de.Maxr1998.modernpreferences.helpers.onClick -import de.Maxr1998.modernpreferences.helpers.pref -import dev.msfjarvis.aps.ui.pgp.PGPKeyImportActivity -import dev.msfjarvis.aps.util.extensions.launchActivity -import dev.msfjarvis.aps.util.features.Feature - -class PGPSettings(private val activity: FragmentActivity) : SettingsProvider { - - override fun provideSettings(builder: PreferenceScreen.Builder) { - builder.apply { - val enablePGPainless = - checkBox(Feature.EnablePGPainlessBackend.configKey) { - title = "Enable new PGP backend" - persistent = true - } - pref("_") { - title = "Import PGP key" - persistent = false - dependency = enablePGPainless.key - onClick { - activity.launchActivity(PGPKeyImportActivity::class.java) - false - } - } - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/PasswordSettings.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/PasswordSettings.kt deleted file mode 100644 index db3d3670..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/PasswordSettings.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.settings - -import android.text.InputType -import androidx.fragment.app.FragmentActivity -import de.Maxr1998.modernpreferences.PreferenceScreen -import de.Maxr1998.modernpreferences.helpers.checkBox -import de.Maxr1998.modernpreferences.helpers.editText -import de.Maxr1998.modernpreferences.helpers.onSelectionChange -import de.Maxr1998.modernpreferences.helpers.singleChoice -import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.util.settings.PreferenceKeys - -class PasswordSettings(private val activity: FragmentActivity) : SettingsProvider { - - override fun provideSettings(builder: PreferenceScreen.Builder) { - builder.apply { - val values = activity.resources.getStringArray(R.array.pwgen_provider_values) - val labels = activity.resources.getStringArray(R.array.pwgen_provider_labels) - val items = values.zip(labels).map { SelectionItem(it.first, it.second, null) } - singleChoice( - PreferenceKeys.PREF_KEY_PWGEN_TYPE, - items, - ) { - initialSelection = "classic" - titleRes = R.string.pref_password_generator_type_title - onSelectionChange { true } - } - editText(PreferenceKeys.GENERAL_SHOW_TIME) { - titleRes = R.string.pref_clipboard_timeout_title - summaryProvider = { timeout -> - activity.getString(R.string.pref_clipboard_timeout_summary, timeout ?: "45") - } - textInputType = InputType.TYPE_CLASS_NUMBER - } - checkBox(PreferenceKeys.SHOW_PASSWORD) { - titleRes = R.string.show_password_pref_title - summaryRes = R.string.show_password_pref_summary - defaultValue = true - } - checkBox(PreferenceKeys.COPY_ON_DECRYPT) { - titleRes = R.string.pref_copy_title - summaryRes = R.string.pref_copy_summary - defaultValue = false - } - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/RepositorySettings.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/RepositorySettings.kt deleted file mode 100644 index df34b145..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/RepositorySettings.kt +++ /dev/null @@ -1,194 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.settings - -import android.content.Intent -import android.content.SharedPreferences -import android.content.pm.ShortcutManager -import android.os.Build -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.edit -import androidx.core.content.getSystemService -import androidx.fragment.app.FragmentActivity -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.android.EntryPointAccessors -import dagger.hilt.components.SingletonComponent -import de.Maxr1998.modernpreferences.Preference -import de.Maxr1998.modernpreferences.PreferenceScreen -import de.Maxr1998.modernpreferences.helpers.checkBox -import de.Maxr1998.modernpreferences.helpers.onClick -import de.Maxr1998.modernpreferences.helpers.pref -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.repo.PasswordRepository -import dev.msfjarvis.aps.injection.prefs.GitPreferences -import dev.msfjarvis.aps.ui.git.config.GitConfigActivity -import dev.msfjarvis.aps.ui.git.config.GitServerConfigActivity -import dev.msfjarvis.aps.ui.proxy.ProxySelectorActivity -import dev.msfjarvis.aps.ui.sshkeygen.ShowSshKeyFragment -import dev.msfjarvis.aps.ui.sshkeygen.SshKeyGenActivity -import dev.msfjarvis.aps.ui.sshkeygen.SshKeyImportActivity -import dev.msfjarvis.aps.util.extensions.getString -import dev.msfjarvis.aps.util.extensions.launchActivity -import dev.msfjarvis.aps.util.extensions.sharedPrefs -import dev.msfjarvis.aps.util.extensions.snackbar -import dev.msfjarvis.aps.util.extensions.unsafeLazy -import dev.msfjarvis.aps.util.git.sshj.SshKey -import dev.msfjarvis.aps.util.settings.GitSettings -import dev.msfjarvis.aps.util.settings.PreferenceKeys - -class RepositorySettings(private val activity: FragmentActivity) : SettingsProvider { - - private val generateSshKey = - activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - showSshKeyPref?.visible = SshKey.canShowSshPublicKey - } - - private val hiltEntryPoint by unsafeLazy { - EntryPointAccessors.fromApplication( - activity.applicationContext, - RepositorySettingsEntryPoint::class.java, - ) - } - - private var showSshKeyPref: Preference? = null - - override fun provideSettings(builder: PreferenceScreen.Builder) { - val encryptedPreferences = hiltEntryPoint.encryptedPreferences() - val gitSettings = hiltEntryPoint.gitSettings() - - builder.apply { - checkBox(PreferenceKeys.REBASE_ON_PULL) { - titleRes = R.string.pref_rebase_on_pull_title - summaryRes = R.string.pref_rebase_on_pull_summary - summaryOnRes = R.string.pref_rebase_on_pull_summary_on - defaultValue = true - } - pref(PreferenceKeys.GIT_SERVER_INFO) { - titleRes = R.string.pref_edit_git_server_settings - visible = PasswordRepository.isGitRepo() - onClick { - activity.launchActivity(GitServerConfigActivity::class.java) - true - } - } - pref(PreferenceKeys.PROXY_SETTINGS) { - titleRes = R.string.pref_edit_proxy_settings - visible = gitSettings.url?.startsWith("https") == true && PasswordRepository.isGitRepo() - onClick { - activity.launchActivity(ProxySelectorActivity::class.java) - true - } - } - pref(PreferenceKeys.GIT_CONFIG) { - titleRes = R.string.pref_edit_git_config - visible = PasswordRepository.isGitRepo() - onClick { - activity.launchActivity(GitConfigActivity::class.java) - true - } - } - pref(PreferenceKeys.SSH_KEY) { - titleRes = R.string.pref_import_ssh_key_title - visible = PasswordRepository.isGitRepo() - onClick { - activity.launchActivity(SshKeyImportActivity::class.java) - true - } - } - pref(PreferenceKeys.SSH_KEYGEN) { - titleRes = R.string.pref_ssh_keygen_title - onClick { - generateSshKey.launch(Intent(activity, SshKeyGenActivity::class.java)) - true - } - } - showSshKeyPref = - pref(PreferenceKeys.SSH_SEE_KEY) { - titleRes = R.string.pref_ssh_see_key_title - visible = PasswordRepository.isGitRepo() && SshKey.canShowSshPublicKey - onClick { - ShowSshKeyFragment().show(activity.supportFragmentManager, "public_key") - true - } - } - pref(PreferenceKeys.CLEAR_SAVED_PASS) { - fun Preference.updatePref() { - val sshPass = encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) - val httpsPass = encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD) - if (sshPass == null && httpsPass == null) { - visible = false - return - } - when { - httpsPass != null -> titleRes = R.string.clear_saved_passphrase_https - sshPass != null -> titleRes = R.string.clear_saved_passphrase_ssh - } - visible = true - requestRebind() - } - onClick { - updatePref() - true - } - updatePref() - } - pref(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID) { - titleRes = R.string.pref_title_openkeystore_clear_keyid - visible = - activity.sharedPrefs.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty() - ?: false - onClick { - activity.sharedPrefs.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) } - visible = false - true - } - } - pref(PreferenceKeys.GIT_DELETE_REPO) { - titleRes = R.string.pref_git_delete_repo_title - summaryRes = R.string.pref_git_delete_repo_summary - onClick { - val repoDir = PasswordRepository.getRepositoryDirectory() - MaterialAlertDialogBuilder(activity) - .setTitle(R.string.pref_dialog_delete_title) - .setMessage(activity.getString(R.string.dialog_delete_msg, repoDir)) - .setCancelable(false) - .setPositiveButton(R.string.dialog_delete) { dialogInterface, _ -> - runCatching { - PasswordRepository.getRepositoryDirectory().deleteRecursively() - PasswordRepository.closeRepository() - } - .onFailure { it.message?.let { message -> activity.snackbar(message = message) } } - - if (Build.VERSION.SDK_INT >= 25) { - activity.getSystemService()?.apply { - removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList()) - } - } - activity.sharedPrefs.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) } - dialogInterface.cancel() - activity.finish() - } - .setNegativeButton(R.string.dialog_do_not_delete) { dialogInterface, _ -> - run { dialogInterface.cancel() } - } - .show() - true - } - } - } - } - - @EntryPoint - @InstallIn(SingletonComponent::class) - interface RepositorySettingsEntryPoint { - fun gitSettings(): GitSettings - @GitPreferences fun encryptedPreferences(): SharedPreferences - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsActivity.kt deleted file mode 100644 index d31aa630..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsActivity.kt +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.settings - -import android.os.Bundle -import android.view.MenuItem -import androidx.appcompat.app.AppCompatActivity -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import de.Maxr1998.modernpreferences.Preference -import de.Maxr1998.modernpreferences.PreferencesAdapter -import de.Maxr1998.modernpreferences.helpers.screen -import de.Maxr1998.modernpreferences.helpers.subScreen -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.databinding.ActivityPreferenceRecyclerviewBinding -import dev.msfjarvis.aps.util.extensions.viewBinding - -class SettingsActivity : AppCompatActivity() { - - private val miscSettings = MiscSettings(this) - private val autofillSettings = AutofillSettings(this) - private val passwordSettings = PasswordSettings(this) - private val repositorySettings = RepositorySettings(this) - private val generalSettings = GeneralSettings(this) - private val pgpSettings = PGPSettings(this) - - private val binding by viewBinding(ActivityPreferenceRecyclerviewBinding::inflate) - private val preferencesAdapter: PreferencesAdapter - get() = binding.preferenceRecyclerView.adapter as PreferencesAdapter - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - Preference.Config.dialogBuilderFactory = { context -> MaterialAlertDialogBuilder(context) } - val screen = - screen(this) { - subScreen { - titleRes = R.string.pref_category_general_title - iconRes = R.drawable.app_settings_alt_24px - generalSettings.provideSettings(this) - } - subScreen { - titleRes = R.string.pref_category_autofill_title - iconRes = R.drawable.ic_wysiwyg_24px - autofillSettings.provideSettings(this) - } - subScreen { - titleRes = R.string.pref_category_passwords_title - iconRes = R.drawable.ic_password_24px - passwordSettings.provideSettings(this) - } - subScreen { - titleRes = R.string.pref_category_repository_title - iconRes = R.drawable.ic_call_merge_24px - repositorySettings.provideSettings(this) - } - subScreen { - titleRes = R.string.pref_category_misc_title - iconRes = R.drawable.ic_miscellaneous_services_24px - miscSettings.provideSettings(this) - } - subScreen { - titleRes = R.string.pref_category_pgp_title - iconRes = R.drawable.ic_lock_open_24px - pgpSettings.provideSettings(this) - } - } - val adapter = PreferencesAdapter(screen) - adapter.onScreenChangeListener = - PreferencesAdapter.OnScreenChangeListener { subScreen, entering -> - supportActionBar?.title = - if (!entering) { - getString(R.string.action_settings) - } else { - getString(subScreen.titleRes) - } - } - savedInstanceState - ?.getParcelable("adapter") - ?.let(adapter::loadSavedState) - binding.preferenceRecyclerView.adapter = adapter - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putParcelable("adapter", preferencesAdapter.getSavedState()) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> - if (!preferencesAdapter.goBack()) { - super.onOptionsItemSelected(item) - } else { - true - } - else -> super.onOptionsItemSelected(item) - } - } - - override fun onBackPressed() { - if (!preferencesAdapter.goBack()) super.onBackPressed() - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsProvider.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsProvider.kt deleted file mode 100644 index 61b11064..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsProvider.kt +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.settings - -import de.Maxr1998.modernpreferences.PreferenceScreen - -/** Used to generate a uniform API for all settings UI classes. */ -interface SettingsProvider { - - /** Inserts the settings items for the class into the given [builder]. */ - fun provideSettings(builder: PreferenceScreen.Builder) -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/ShowSshKeyFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/ShowSshKeyFragment.kt deleted file mode 100644 index 73cd2ba1..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/ShowSshKeyFragment.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.ui.sshkeygen - -import android.app.Dialog -import android.content.Intent -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.util.git.sshj.SshKey - -class ShowSshKeyFragment : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val activity = requireActivity() - val publicKey = SshKey.sshPublicKey - return MaterialAlertDialogBuilder(requireActivity()).run { - setMessage(getString(R.string.ssh_keygen_message, publicKey)) - setTitle(R.string.your_public_key) - setNegativeButton(R.string.ssh_keygen_later) { _, _ -> - (activity as? SshKeyGenActivity)?.finish() - } - setPositiveButton(R.string.ssh_keygen_share) { _, _ -> - val sendIntent = - Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, publicKey) - } - startActivity(Intent.createChooser(sendIntent, null)) - (activity as? SshKeyGenActivity)?.finish() - } - create() - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyGenActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyGenActivity.kt deleted file mode 100644 index dec0c135..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyGenActivity.kt +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.ui.sshkeygen - -import android.content.SharedPreferences -import android.os.Bundle -import android.security.keystore.UserNotAuthenticatedException -import android.view.MenuItem -import android.view.View -import android.view.inputmethod.InputMethodManager -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.core.content.getSystemService -import androidx.lifecycle.lifecycleScope -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.databinding.ActivitySshKeygenBinding -import dev.msfjarvis.aps.injection.prefs.GitPreferences -import dev.msfjarvis.aps.util.auth.BiometricAuthenticator -import dev.msfjarvis.aps.util.auth.BiometricAuthenticator.Result -import dev.msfjarvis.aps.util.extensions.keyguardManager -import dev.msfjarvis.aps.util.extensions.viewBinding -import dev.msfjarvis.aps.util.git.sshj.SshKey -import javax.inject.Inject -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -private enum class KeyGenType(val generateKey: suspend (requireAuthentication: Boolean) -> Unit) { - Rsa({ requireAuthentication -> - SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication) - }), - Ecdsa({ requireAuthentication -> - SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication) - }), - Ed25519({ requireAuthentication -> - SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication) - }), -} - -@AndroidEntryPoint -class SshKeyGenActivity : AppCompatActivity() { - - private var keyGenType = KeyGenType.Ecdsa - private val binding by viewBinding(ActivitySshKeygenBinding::inflate) - @GitPreferences @Inject lateinit var gitPrefs: SharedPreferences - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - with(binding) { - generate.setOnClickListener { - if (SshKey.exists) { - MaterialAlertDialogBuilder(this@SshKeyGenActivity).run { - setTitle(R.string.ssh_keygen_existing_title) - setMessage(R.string.ssh_keygen_existing_message) - setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> - lifecycleScope.launch { generate() } - } - setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> - setResult(RESULT_CANCELED) - finish() - } - show() - } - } else { - lifecycleScope.launch { generate() } - } - } - keyTypeGroup.check(R.id.key_type_ecdsa) - keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa) - keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked -> - if (isChecked) { - keyGenType = - when (checkedId) { - R.id.key_type_ed25519 -> KeyGenType.Ed25519 - R.id.key_type_ecdsa -> KeyGenType.Ecdsa - R.id.key_type_rsa -> KeyGenType.Rsa - else -> throw IllegalStateException("Impossible key type selection") - } - keyTypeExplanation.setText( - when (keyGenType) { - KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519 - KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa - KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa - } - ) - } - } - keyRequireAuthentication.isEnabled = keyguardManager.isDeviceSecure - keyRequireAuthentication.isChecked = keyRequireAuthentication.isEnabled - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - onBackPressed() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - private suspend fun generate() { - binding.generate.apply { - text = getString(R.string.ssh_key_gen_generating_progress) - isEnabled = false - } - binding.generate.text = getString(R.string.ssh_key_gen_generating_progress) - val result = runCatching { - withContext(Dispatchers.IO) { - val requireAuthentication = binding.keyRequireAuthentication.isChecked - if (requireAuthentication) { - val result = - withContext(Dispatchers.Main) { - suspendCoroutine { cont -> - BiometricAuthenticator.authenticate( - this@SshKeyGenActivity, - R.string.biometric_prompt_title_ssh_keygen - ) { result -> - // Do not cancel on failed attempts as these are handled by the - // authenticator UI. - if (result !is Result.Retry) cont.resume(result) - } - } - } - if (result !is Result.Success) - throw UserNotAuthenticatedException(getString(R.string.biometric_auth_generic_failure)) - } - keyGenType.generateKey(requireAuthentication) - } - } - gitPrefs.edit { remove("ssh_key_local_passphrase") } - binding.generate.apply { - text = getString(R.string.ssh_keygen_generate) - isEnabled = true - } - result.fold( - success = { ShowSshKeyFragment().show(supportFragmentManager, "public_key") }, - failure = { e -> - e.printStackTrace() - MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.error_generate_ssh_key)) - .setMessage(getString(R.string.ssh_key_error_dialog_text) + e.message) - .setPositiveButton(getString(R.string.dialog_ok)) { _, _ -> - setResult(RESULT_OK) - finish() - } - .show() - }, - ) - hideKeyboard() - } - - private fun hideKeyboard() { - val imm = getSystemService() ?: return - var view = currentFocus - if (view == null) { - view = View(this) - } - imm.hideSoftInputFromWindow(view.windowToken, 0) - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyImportActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyImportActivity.kt deleted file mode 100644 index 446e0c32..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyImportActivity.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.sshkeygen - -import android.net.Uri -import android.os.Bundle -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.util.git.sshj.SshKey - -class SshKeyImportActivity : AppCompatActivity() { - - private val sshKeyImportAction = - registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> - if (uri == null) { - finish() - return@registerForActivityResult - } - runCatching { - SshKey.import(uri) - Toast.makeText( - this, - resources.getString(R.string.ssh_key_success_dialog_title), - Toast.LENGTH_LONG - ) - .show() - setResult(RESULT_OK) - finish() - } - .onFailure { e -> - MaterialAlertDialogBuilder(this) - .setTitle(resources.getString(R.string.ssh_key_error_dialog_title)) - .setMessage(e.message) - .setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> finish() } - .show() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (SshKey.exists) { - MaterialAlertDialogBuilder(this).run { - setTitle(R.string.ssh_keygen_existing_title) - setMessage(R.string.ssh_keygen_existing_message) - setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> importSshKey() } - setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() } - setOnCancelListener { finish() } - show() - } - } else { - importSshKey() - } - } - - private fun importSshKey() { - sshKeyImportAction.launch(arrayOf("*/*")) - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt b/app/src/main/java/dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt deleted file mode 100644 index 494a9ed7..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.ui.util - -import androidx.recyclerview.widget.DefaultItemAnimator -import androidx.recyclerview.widget.RecyclerView - -class OnOffItemAnimator : DefaultItemAnimator() { - - var isEnabled: Boolean = true - set(value) { - // Defer update until no animation is running anymore. - isRunning { field = value } - } - - private fun dontAnimate(viewHolder: RecyclerView.ViewHolder): Boolean { - dispatchAnimationFinished(viewHolder) - return false - } - - override fun animateAppearance( - viewHolder: RecyclerView.ViewHolder, - preLayoutInfo: ItemHolderInfo?, - postLayoutInfo: ItemHolderInfo - ): Boolean { - return if (isEnabled) { - super.animateAppearance(viewHolder, preLayoutInfo, postLayoutInfo) - } else { - dontAnimate(viewHolder) - } - } - - override fun animateChange( - oldHolder: RecyclerView.ViewHolder, - newHolder: RecyclerView.ViewHolder, - preInfo: ItemHolderInfo, - postInfo: ItemHolderInfo - ): Boolean { - return if (isEnabled) { - super.animateChange(oldHolder, newHolder, preInfo, postInfo) - } else { - dontAnimate(oldHolder) - } - } - - override fun animateDisappearance( - viewHolder: RecyclerView.ViewHolder, - preLayoutInfo: ItemHolderInfo, - postLayoutInfo: ItemHolderInfo? - ): Boolean { - return if (isEnabled) { - super.animateDisappearance(viewHolder, preLayoutInfo, postLayoutInfo) - } else { - dontAnimate(viewHolder) - } - } - - override fun animatePersistence( - viewHolder: RecyclerView.ViewHolder, - preInfo: ItemHolderInfo, - postInfo: ItemHolderInfo - ): Boolean { - return if (isEnabled) { - super.animatePersistence(viewHolder, preInfo, postInfo) - } else { - dontAnimate(viewHolder) - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt b/app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt deleted file mode 100644 index 38ff812a..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.auth - -import android.app.KeyguardManager -import androidx.annotation.StringRes -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricManager.Authenticators -import androidx.biometric.BiometricPrompt -import androidx.core.content.ContextCompat -import androidx.core.content.getSystemService -import androidx.fragment.app.FragmentActivity -import dev.msfjarvis.aps.R -import logcat.logcat - -object BiometricAuthenticator { - - private const val TAG = "BiometricAuthenticator" - private const val validAuthenticators = - Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK - - /** - * Sealed class to wrap [BiometricPrompt]'s [Int]-based return codes into more easily-interpreted - * types. - */ - sealed class Result { - - /** Biometric authentication was a success. */ - data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result() - - /** Biometric authentication has irreversibly failed. */ - data class Failure(val code: Int?, val message: CharSequence) : Result() - - /** - * An incorrect biometric was entered, but the prompt UI is offering the option to retry the - * operation. - */ - object Retry : Result() - - /** The biometric hardware is unavailable or disabled on a software or hardware level. */ - object HardwareUnavailableOrDisabled : Result() - - /** The prompt was dismissed. */ - object Cancelled : Result() - } - - fun canAuthenticate(activity: FragmentActivity): Boolean { - return BiometricManager.from(activity).canAuthenticate(validAuthenticators) == - BiometricManager.BIOMETRIC_SUCCESS - } - - fun authenticate( - activity: FragmentActivity, - @StringRes dialogTitleRes: Int = R.string.biometric_prompt_title, - callback: (Result) -> Unit - ) { - val authCallback = - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - super.onAuthenticationError(errorCode, errString) - logcat(TAG) { "BiometricAuthentication error: errorCode=$errorCode, msg=$errString" } - callback( - when (errorCode) { - BiometricPrompt.ERROR_CANCELED, - BiometricPrompt.ERROR_USER_CANCELED, - BiometricPrompt.ERROR_NEGATIVE_BUTTON -> { - Result.Cancelled - } - BiometricPrompt.ERROR_HW_NOT_PRESENT, - BiometricPrompt.ERROR_HW_UNAVAILABLE, - BiometricPrompt.ERROR_NO_BIOMETRICS, - BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> { - Result.HardwareUnavailableOrDisabled - } - BiometricPrompt.ERROR_LOCKOUT, - BiometricPrompt.ERROR_LOCKOUT_PERMANENT, - BiometricPrompt.ERROR_NO_SPACE, - BiometricPrompt.ERROR_TIMEOUT, - BiometricPrompt.ERROR_VENDOR -> { - Result.Failure( - errorCode, - activity.getString(R.string.biometric_auth_error_reason, errString) - ) - } - BiometricPrompt.ERROR_UNABLE_TO_PROCESS -> { - Result.Retry - } - // We cover all guaranteed values above, but [errorCode] is still an Int - // at the end of - // the day so a - // catch-all else will always be required. - else -> { - Result.Failure( - errorCode, - activity.getString(R.string.biometric_auth_error_reason, errString) - ) - } - } - ) - } - - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - callback(Result.Retry) - } - - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - super.onAuthenticationSucceeded(result) - callback(Result.Success(result.cryptoObject)) - } - } - val deviceHasKeyguard = activity.getSystemService()?.isDeviceSecure == true - if (canAuthenticate(activity) || deviceHasKeyguard) { - val promptInfo = - BiometricPrompt.PromptInfo.Builder() - .setTitle(activity.getString(dialogTitleRes)) - .setAllowedAuthenticators(validAuthenticators) - .build() - BiometricPrompt( - activity, - ContextCompat.getMainExecutor(activity.applicationContext), - authCallback - ) - .authenticate(promptInfo) - } else { - callback(Result.HardwareUnavailableOrDisabled) - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt deleted file mode 100644 index 745bad73..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt +++ /dev/null @@ -1,236 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.util.autofill - -import android.content.Context -import android.content.IntentSender -import android.service.autofill.Dataset -import android.service.autofill.FillCallback -import android.service.autofill.FillResponse -import android.service.autofill.SaveInfo -import android.view.inputmethod.InlineSuggestionsRequest -import android.widget.inline.InlinePresentationSpec -import androidx.annotation.RequiresApi -import com.github.androidpasswordstore.autofillparser.AutofillAction -import com.github.androidpasswordstore.autofillparser.FillableForm -import com.github.androidpasswordstore.autofillparser.fillWith -import com.github.michaelbull.result.fold -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dev.msfjarvis.aps.autofill.oreo.ui.AutofillSmsActivity -import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivity -import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivityV2 -import dev.msfjarvis.aps.ui.autofill.AutofillFilterView -import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity -import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity -import dev.msfjarvis.aps.util.features.Feature -import dev.msfjarvis.aps.util.features.Features -import java.io.File -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat - -/** Implements [AutofillResponseBuilder]'s methods for API 30 and above */ -@RequiresApi(30) -class Api30AutofillResponseBuilder -@AssistedInject -constructor( - @Assisted form: FillableForm, - private val features: Features, -) { - - @AssistedFactory - interface Factory { - fun create(form: FillableForm): Api30AutofillResponseBuilder - } - - private val formOrigin = form.formOrigin - private val scenario = form.scenario - private val ignoredIds = form.ignoredIds - private val saveFlags = form.saveFlags - private val clientState = form.toClientState() - - // We do not offer save when the only relevant field is a username field or there is no field. - private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave - private val canBeSaved = saveFlags != null && scenarioSupportsSave - - private fun makeIntentDataset( - context: Context, - action: AutofillAction, - intentSender: IntentSender, - metadata: DatasetMetadata, - imeSpec: InlinePresentationSpec?, - ): Dataset { - return Dataset.Builder(makeRemoteView(context, metadata)).run { - fillWith(scenario, action, credentials = null) - setAuthentication(intentSender) - if (imeSpec != null) { - val inlinePresentation = makeInlinePresentation(context, imeSpec, metadata) - if (inlinePresentation != null) { - setInlinePresentation(inlinePresentation) - } - } - build() - } - } - - private fun makeMatchDataset( - context: Context, - file: File, - imeSpec: InlinePresentationSpec? - ): Dataset? { - if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null - val metadata = makeFillMatchMetadata(context, file) - val intentSender = - if (features.isEnabled(Feature.EnablePGPainlessBackend)) { - AutofillDecryptActivityV2.makeDecryptFileIntentSender(file, context) - } else { - AutofillDecryptActivity.makeDecryptFileIntentSender(file, context) - } - return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec) - } - - private fun makeSearchDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? { - if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null - val metadata = makeSearchAndFillMetadata(context) - val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin) - return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata, imeSpec) - } - - private fun makeGenerateDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? { - if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null - val metadata = makeGenerateAndFillMetadata(context) - val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin) - return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata, imeSpec) - } - - private fun makeFillOtpFromSmsDataset( - context: Context, - imeSpec: InlinePresentationSpec? - ): Dataset? { - if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null - if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null - val metadata = makeFillOtpFromSmsMetadata(context) - val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context) - return makeIntentDataset( - context, - AutofillAction.FillOtpFromSms, - intentSender, - metadata, - imeSpec - ) - } - - private fun makePublisherChangedDataset( - context: Context, - publisherChangedException: AutofillPublisherChangedException, - imeSpec: InlinePresentationSpec? - ): Dataset { - val metadata = makeWarningMetadata(context) - // If the user decides to trust the new publisher, they can choose reset the list of - // matches. In this case we need to immediately show a new `FillResponse` as if the app were - // autofilled for the first time. This `FillResponse` needs to be returned as a result from - // `AutofillPublisherChangedActivity`, which is why we create and pass it on here. - val fillResponseAfterReset = makeFillResponse(context, null, emptyList()) - val intentSender = - AutofillPublisherChangedActivity.makePublisherChangedIntentSender( - context, - publisherChangedException, - fillResponseAfterReset - ) - return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec) - } - - private fun makePublisherChangedResponse( - context: Context, - inlineSuggestionsRequest: InlineSuggestionsRequest?, - publisherChangedException: AutofillPublisherChangedException - ): FillResponse { - val imeSpec = inlineSuggestionsRequest?.inlinePresentationSpecs?.firstOrNull() - return FillResponse.Builder().run { - addDataset(makePublisherChangedDataset(context, publisherChangedException, imeSpec)) - setIgnoredIds(*ignoredIds.toTypedArray()) - build() - } - } - - private fun makeFillResponse( - context: Context, - inlineSuggestionsRequest: InlineSuggestionsRequest?, - matchedFiles: List - ): FillResponse? { - var datasetCount = 0 - val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList() - return FillResponse.Builder().run { - for (file in matchedFiles) { - makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let { - datasetCount++ - addDataset(it) - } - } - makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let { - datasetCount++ - addDataset(it) - } - makeFillOtpFromSmsDataset(context, imeSpecs.getOrNull(datasetCount))?.let { - datasetCount++ - addDataset(it) - } - makeSearchDataset(context, imeSpecs.getOrNull(datasetCount))?.let { - datasetCount++ - addDataset(it) - } - if (datasetCount == 0) return null - setHeader( - makeRemoteView( - context, - makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)) - ) - ) - makeSaveInfo()?.let { setSaveInfo(it) } - setClientState(clientState) - setIgnoredIds(*ignoredIds.toTypedArray()) - build() - } - } - - // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE - // See: - // https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE - private fun makeSaveInfo(): SaveInfo? { - if (!canBeSaved) return null - check(saveFlags != null) - val idsToSave = scenario.fieldsToSave.toTypedArray() - if (idsToSave.isEmpty()) return null - var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD - if (scenario.hasUsername) { - saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME - } - return SaveInfo.Builder(saveDataTypes, idsToSave).run { - setFlags(saveFlags) - build() - } - } - - /** Creates and returns a suitable [FillResponse] to the Autofill framework. */ - fun fillCredentials( - context: Context, - inlineSuggestionsRequest: InlineSuggestionsRequest?, - callback: FillCallback - ) { - AutofillMatcher.getMatchesFor(context, formOrigin) - .fold( - success = { matchedFiles -> - callback.onSuccess(makeFillResponse(context, inlineSuggestionsRequest, matchedFiles)) - }, - failure = { e -> - logcat(ERROR) { e.asLog() } - callback.onSuccess(makePublisherChangedResponse(context, inlineSuggestionsRequest, e)) - } - ) - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt deleted file mode 100644 index 42c1d693..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt +++ /dev/null @@ -1,202 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.autofill - -import android.content.Context -import android.content.SharedPreferences -import android.widget.Toast -import androidx.core.content.edit -import com.github.androidpasswordstore.autofillparser.FormOrigin -import com.github.androidpasswordstore.autofillparser.computeCertificatesHash -import com.github.michaelbull.result.Err -import com.github.michaelbull.result.Ok -import com.github.michaelbull.result.Result -import dev.msfjarvis.aps.R -import java.io.File -import logcat.LogPriority.ERROR -import logcat.LogPriority.WARN -import logcat.logcat - -private const val PREFERENCES_AUTOFILL_APP_MATCHES = "oreo_autofill_app_matches" -private val Context.autofillAppMatches - get() = getSharedPreferences(PREFERENCES_AUTOFILL_APP_MATCHES, Context.MODE_PRIVATE) - -private const val PREFERENCES_AUTOFILL_WEB_MATCHES = "oreo_autofill_web_matches" -private val Context.autofillWebMatches - get() = getSharedPreferences(PREFERENCES_AUTOFILL_WEB_MATCHES, Context.MODE_PRIVATE) - -private fun Context.matchPreferences(formOrigin: FormOrigin): SharedPreferences { - return when (formOrigin) { - is FormOrigin.App -> autofillAppMatches - is FormOrigin.Web -> autofillWebMatches - } -} - -class AutofillPublisherChangedException(val formOrigin: FormOrigin) : - Exception( - "The publisher of '${formOrigin.identifier}' changed since an entry was first matched with this app" - ) { - - init { - require(formOrigin is FormOrigin.App) - } -} - -/** Manages "matches", i.e., associations between apps or websites and Password Store entries. */ -class AutofillMatcher { - - companion object { - - private const val MAX_NUM_MATCHES = 10 - - private const val PREFERENCE_PREFIX_TOKEN = "token;" - private fun tokenKey(formOrigin: FormOrigin.App) = - "$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}" - - private const val PREFERENCE_PREFIX_MATCHES = "matches;" - private fun matchesKey(formOrigin: FormOrigin) = - "$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}" - - private fun hasFormOriginHashChanged(context: Context, formOrigin: FormOrigin): Boolean { - return when (formOrigin) { - is FormOrigin.Web -> false - is FormOrigin.App -> { - val packageName = formOrigin.identifier - val certificatesHash = computeCertificatesHash(context, packageName) - val storedCertificatesHash = - context.autofillAppMatches.getString(tokenKey(formOrigin), null) ?: return false - val hashHasChanged = certificatesHash != storedCertificatesHash - if (hashHasChanged) { - logcat(ERROR) { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" } - true - } else { - false - } - } - } - } - - private fun storeFormOriginHash(context: Context, formOrigin: FormOrigin) { - if (formOrigin is FormOrigin.App) { - val packageName = formOrigin.identifier - val certificatesHash = computeCertificatesHash(context, packageName) - context.autofillAppMatches.edit { putString(tokenKey(formOrigin), certificatesHash) } - } - // We don't need to store a hash for FormOrigin.Web since it can only originate from - // browsers we trust to verify the origin. - } - - /** - * Get all Password Store entries that have already been associated with [formOrigin] by the - * user. - * - * If [formOrigin] represents an app and that app's certificates have changed since the first - * time the user associated an entry with it, an [AutofillPublisherChangedException] will be - * thrown. - */ - fun getMatchesFor( - context: Context, - formOrigin: FormOrigin - ): Result, AutofillPublisherChangedException> { - if (hasFormOriginHashChanged(context, formOrigin)) { - return Err(AutofillPublisherChangedException(formOrigin)) - } - val matchPreferences = context.matchPreferences(formOrigin) - val matchedFiles = - matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) } - return Ok( - matchedFiles - .filter { it.exists() } - .also { validFiles -> - matchPreferences.edit { - putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet()) - } - } - ) - } - - fun clearMatchesFor(context: Context, formOrigin: FormOrigin) { - context.matchPreferences(formOrigin).edit { - remove(matchesKey(formOrigin)) - if (formOrigin is FormOrigin.App) remove(tokenKey(formOrigin)) - } - } - - /** - * Associates the store entry [file] with [formOrigin], such that future Autofill responses to - * requests from this app or website offer this entry as a dataset. - * - * The maximum number of matches is limited by [MAX_NUM_MATCHES] since older versions of Android - * may crash when too many datasets are offered. - */ - fun addMatchFor(context: Context, formOrigin: FormOrigin, file: File) { - if (!file.exists()) return - if (hasFormOriginHashChanged(context, formOrigin)) { - // This should never happen since we already verified the publisher in - // getMatchesFor. - logcat(ERROR) { "App publisher changed between getMatchesFor and addMatchFor" } - throw AutofillPublisherChangedException(formOrigin) - } - val matchPreferences = context.matchPreferences(formOrigin) - val matchedFiles = - matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) } - val newFiles = setOf(file.absoluteFile).union(matchedFiles) - if (newFiles.size > MAX_NUM_MATCHES) { - Toast.makeText( - context, - context.getString(R.string.oreo_autofill_max_matches_reached, MAX_NUM_MATCHES), - Toast.LENGTH_LONG - ) - .show() - return - } - matchPreferences.edit { - putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet()) - } - storeFormOriginHash(context, formOrigin) - logcat { "Stored match for $formOrigin" } - } - - /** - * Goes through all existing matches and updates their associated entries by using [moveFromTo] - * as a lookup table and deleting the matches for files in [delete]. - */ - fun updateMatches( - context: Context, - moveFromTo: Map = emptyMap(), - delete: Collection = emptyList() - ) { - val deletePathList = delete.map { it.absolutePath } - val oldNewPathMap = - moveFromTo.mapValues { it.value.absolutePath }.mapKeys { it.key.absolutePath } - for (prefs in listOf(context.autofillAppMatches, context.autofillWebMatches)) { - for ((key, value) in prefs.all) { - if (!key.startsWith(PREFERENCE_PREFIX_MATCHES)) continue - // We know that preferences starting with `PREFERENCE_PREFIX_MATCHES` were - // created with `putStringSet`. - @Suppress("UNCHECKED_CAST") val oldMatches = value as? Set - if (oldMatches == null) { - logcat(WARN) { "Failed to read matches for $key" } - continue - } - // Delete all matches for file locations that are going to be overwritten, then - // transfer matches over to the files at their new locations. - val newMatches = - oldMatches - .asSequence() - .minus(deletePathList) - .minus(oldNewPathMap.values) - .map { match -> - val newPath = oldNewPathMap[match] ?: return@map match - logcat { "Updating match for $key: $match --> $newPath" } - newPath - } - .toSet() - if (newMatches != oldMatches) prefs.edit { putStringSet(key, newMatches) } - } - } - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt deleted file mode 100644 index 6803da47..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.autofill - -import android.content.Context -import androidx.annotation.RequiresApi -import com.github.androidpasswordstore.autofillparser.Credentials -import dev.msfjarvis.aps.data.passfile.PasswordEntry -import dev.msfjarvis.aps.util.extensions.getString -import dev.msfjarvis.aps.util.extensions.sharedPrefs -import dev.msfjarvis.aps.util.services.getDefaultUsername -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import java.io.File -import java.nio.file.Paths -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking - -enum class DirectoryStructure(val value: String) { - EncryptedUsername("encrypted_username"), - FileBased("file"), - DirectoryBased("directory"); - - /** - * Returns the username associated to [file], following the convention of the current - * [DirectoryStructure]. - * - * Examples: - * - * --> null (EncryptedUsername) - * - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased) - * - work/example.org/john@doe.org/password.gpg --> john@doe.org (DirectoryBased) - * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback) - */ - fun getUsernameFor(file: File): String? = - when (this) { - EncryptedUsername -> null - FileBased -> file.nameWithoutExtension - DirectoryBased -> file.parentFile?.name ?: file.nameWithoutExtension - } - - /** - * Returns the origin identifier associated to [file], following the convention of the current - * [DirectoryStructure]. - * - * At least one of [DirectoryStructure.getIdentifierFor] and - * [DirectoryStructure.getAccountPartFor] will always return a non-null result. - * - * Examples: - * - work/example.org.gpg --> example.org (EncryptedUsername) - * - work/example.org/john@doe.org.gpg --> example.org (FileBased) - * - example.org.gpg --> example.org (FileBased, fallback) - * - work/example.org/john@doe.org/password.gpg --> example.org (DirectoryBased) - * - Temporary PIN.gpg --> null (DirectoryBased) - */ - fun getIdentifierFor(file: File): String? = - when (this) { - EncryptedUsername -> file.nameWithoutExtension - FileBased -> file.parentFile?.name ?: file.nameWithoutExtension - DirectoryBased -> file.parentFile?.parent - } - - /** - * Returns the path components of [file] until right before the component that contains the origin - * identifier according to the current [DirectoryStructure]. - * - * Examples: - * - work/example.org.gpg --> work (EncryptedUsername) - * - work/example.org/john@doe.org.gpg --> work (FileBased) - * - example.org/john@doe.org.gpg --> null (FileBased) - * - john@doe.org.gpg --> null (FileBased) - * - work/example.org/john@doe.org/password.gpg --> work (DirectoryBased) - * - example.org/john@doe.org/password.gpg --> null (DirectoryBased) - */ - fun getPathToIdentifierFor(file: File): String? = - when (this) { - EncryptedUsername -> file.parent - FileBased -> file.parentFile?.parent - DirectoryBased -> file.parentFile?.parentFile?.parent - } - - /** - * Returns the path component of [file] following the origin identifier according to the current - * [DirectoryStructure](without file extension). - * - * At least one of [DirectoryStructure.getIdentifierFor] and - * [DirectoryStructure.getAccountPartFor] will always return a non-null result. - * - * Examples: - * - * --> null (EncryptedUsername) - * - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased) - * - example.org.gpg --> null (FileBased, fallback) - * - work/example.org/john@doe.org/password.gpg --> john@doe.org/password (DirectoryBased) - * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback) - */ - fun getAccountPartFor(file: File): String? = - when (this) { - EncryptedUsername -> null - FileBased -> file.nameWithoutExtension.takeIf { file.parentFile != null } - DirectoryBased -> - file.parentFile?.let { parentFile -> "${parentFile.name}/${file.nameWithoutExtension}" } - ?: file.nameWithoutExtension - } - - @RequiresApi(26) - fun getSaveFolderName(sanitizedIdentifier: String, username: String?) = - when (this) { - EncryptedUsername -> "/" - FileBased -> sanitizedIdentifier - DirectoryBased -> Paths.get(sanitizedIdentifier, username ?: "username").toString() - } - - fun getSaveFileName(username: String?, identifier: String) = - when (this) { - EncryptedUsername -> identifier - FileBased -> username - DirectoryBased -> "password" - } - - companion object { - - val DEFAULT = FileBased - - private val reverseMap = values().associateBy { it.value } - fun fromValue(value: String?) = if (value != null) reverseMap[value] ?: DEFAULT else DEFAULT - } -} - -object AutofillPreferences { - - fun directoryStructure(context: Context): DirectoryStructure { - val value = context.sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE) - return DirectoryStructure.fromValue(value) - } - - fun credentialsFromStoreEntry( - context: Context, - file: File, - entry: PasswordEntry, - directoryStructure: DirectoryStructure - ): Credentials { - // Always give priority to a username stored in the encrypted extras - val username = - entry.username ?: directoryStructure.getUsernameFor(file) ?: context.getDefaultUsername() - val totp = if (entry.hasTotp()) runBlocking { entry.totp.first().value } else null - return Credentials(username, entry.password, totp) - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt deleted file mode 100644 index a091e206..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt +++ /dev/null @@ -1,233 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.autofill - -import android.content.Context -import android.content.IntentSender -import android.os.Build -import android.os.Bundle -import android.service.autofill.Dataset -import android.service.autofill.FillCallback -import android.service.autofill.FillResponse -import android.service.autofill.SaveInfo -import androidx.annotation.RequiresApi -import com.github.androidpasswordstore.autofillparser.AutofillAction -import com.github.androidpasswordstore.autofillparser.AutofillScenario -import com.github.androidpasswordstore.autofillparser.Credentials -import com.github.androidpasswordstore.autofillparser.FillableForm -import com.github.androidpasswordstore.autofillparser.fillWith -import com.github.michaelbull.result.fold -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dev.msfjarvis.aps.autofill.oreo.ui.AutofillSmsActivity -import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivity -import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivityV2 -import dev.msfjarvis.aps.ui.autofill.AutofillFilterView -import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity -import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity -import dev.msfjarvis.aps.util.features.Feature -import dev.msfjarvis.aps.util.features.Features -import java.io.File -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat - -@RequiresApi(26) -class AutofillResponseBuilder -@AssistedInject -constructor( - @Assisted form: FillableForm, - private val features: Features, -) { - - @AssistedFactory - interface Factory { - fun create(form: FillableForm): AutofillResponseBuilder - } - - private val formOrigin = form.formOrigin - private val scenario = form.scenario - private val ignoredIds = form.ignoredIds - private val saveFlags = form.saveFlags - private val clientState = form.toClientState() - - // We do not offer save when the only relevant field is a username field or there is no field. - private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave - private val canBeSaved = saveFlags != null && scenarioSupportsSave - - private fun makeIntentDataset( - context: Context, - action: AutofillAction, - intentSender: IntentSender, - metadata: DatasetMetadata, - ): Dataset { - return Dataset.Builder(makeRemoteView(context, metadata)).run { - fillWith(scenario, action, credentials = null) - setAuthentication(intentSender) - build() - } - } - - private fun makeMatchDataset(context: Context, file: File): Dataset? { - if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null - val metadata = makeFillMatchMetadata(context, file) - val intentSender = - if (features.isEnabled(Feature.EnablePGPainlessBackend)) { - AutofillDecryptActivityV2.makeDecryptFileIntentSender(file, context) - } else { - AutofillDecryptActivity.makeDecryptFileIntentSender(file, context) - } - return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata) - } - - private fun makeSearchDataset(context: Context): Dataset? { - if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null - val metadata = makeSearchAndFillMetadata(context) - val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin) - return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata) - } - - private fun makeGenerateDataset(context: Context): Dataset? { - if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null - val metadata = makeGenerateAndFillMetadata(context) - val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin) - return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata) - } - - private fun makeFillOtpFromSmsDataset(context: Context): Dataset? { - if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null - if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null - val metadata = makeFillOtpFromSmsMetadata(context) - val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context) - return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata) - } - - private fun makePublisherChangedDataset( - context: Context, - publisherChangedException: AutofillPublisherChangedException, - ): Dataset { - val metadata = makeWarningMetadata(context) - // If the user decides to trust the new publisher, they can choose reset the list of - // matches. In this case we need to immediately show a new `FillResponse` as if the app were - // autofilled for the first time. This `FillResponse` needs to be returned as a result from - // `AutofillPublisherChangedActivity`, which is why we create and pass it on here. - val fillResponseAfterReset = makeFillResponse(context, emptyList()) - val intentSender = - AutofillPublisherChangedActivity.makePublisherChangedIntentSender( - context, - publisherChangedException, - fillResponseAfterReset - ) - return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata) - } - - private fun makePublisherChangedResponse( - context: Context, - publisherChangedException: AutofillPublisherChangedException - ): FillResponse { - return FillResponse.Builder().run { - addDataset(makePublisherChangedDataset(context, publisherChangedException)) - setIgnoredIds(*ignoredIds.toTypedArray()) - build() - } - } - - // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE - // See: - // https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE - private fun makeSaveInfo(): SaveInfo? { - if (!canBeSaved) return null - check(saveFlags != null) - val idsToSave = scenario.fieldsToSave.toTypedArray() - if (idsToSave.isEmpty()) return null - var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD - if (scenario.hasUsername) { - saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME - } - return SaveInfo.Builder(saveDataTypes, idsToSave).run { - setFlags(saveFlags) - build() - } - } - - private fun makeFillResponse(context: Context, matchedFiles: List): FillResponse? { - var datasetCount = 0 - return FillResponse.Builder().run { - for (file in matchedFiles) { - makeMatchDataset(context, file)?.let { - datasetCount++ - addDataset(it) - } - } - makeGenerateDataset(context)?.let { - datasetCount++ - addDataset(it) - } - makeFillOtpFromSmsDataset(context)?.let { - datasetCount++ - addDataset(it) - } - makeSearchDataset(context)?.let { - datasetCount++ - addDataset(it) - } - if (datasetCount == 0) return null - if (Build.VERSION.SDK_INT >= 28) { - setHeader( - makeRemoteView( - context, - makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)) - ) - ) - } - makeSaveInfo()?.let { setSaveInfo(it) } - setClientState(clientState) - setIgnoredIds(*ignoredIds.toTypedArray()) - build() - } - } - - /** Creates and returns a suitable [FillResponse] to the Autofill framework. */ - fun fillCredentials(context: Context, callback: FillCallback) { - AutofillMatcher.getMatchesFor(context, formOrigin) - .fold( - success = { matchedFiles -> callback.onSuccess(makeFillResponse(context, matchedFiles)) }, - failure = { e -> - logcat(ERROR) { e.asLog() } - callback.onSuccess(makePublisherChangedResponse(context, e)) - } - ) - } - - companion object { - - fun makeFillInDataset( - context: Context, - credentials: Credentials, - clientState: Bundle, - action: AutofillAction - ): Dataset { - val scenario = AutofillScenario.fromClientState(clientState) - // Before Android P, Datasets used for fill-in had to come with a RemoteViews, even - // though they are rarely shown. - // FIXME: We should clone the original dataset here and add the credentials to be filled - // in. Otherwise, the entry in the cached list of datasets will be overwritten by the - // fill-in dataset without any visual representation. This causes it to be missing from - // the Autofill suggestions shown after the user clears the filled out form fields. - val builder = - if (Build.VERSION.SDK_INT >= 28) { - Dataset.Builder() - } else { - Dataset.Builder(makeRemoteView(context, makeEmptyMetadata())) - } - return builder.run { - if (scenario != null) fillWith(scenario, action, credentials) - else logcat(ERROR) { "Failed to recover scenario from client state" } - build() - } - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt deleted file mode 100644 index 20414e6f..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.autofill - -import android.annotation.SuppressLint -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.content.res.Resources -import android.graphics.drawable.Icon -import android.os.Build -import android.service.autofill.InlinePresentation -import android.view.View -import android.widget.RemoteViews -import android.widget.inline.InlinePresentationSpec -import androidx.annotation.DrawableRes -import androidx.autofill.inline.UiVersions -import androidx.autofill.inline.v1.InlineSuggestionUi -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.repo.PasswordRepository -import dev.msfjarvis.aps.ui.passwords.PasswordStore -import java.io.File - -data class DatasetMetadata(val title: String, val subtitle: String?, @DrawableRes val iconRes: Int) - -fun makeRemoteView(context: Context, metadata: DatasetMetadata): RemoteViews { - return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply { - setTextViewText(R.id.title, metadata.title) - if (metadata.subtitle != null) { - setTextViewText(R.id.summary, metadata.subtitle) - } else { - setViewVisibility(R.id.summary, View.GONE) - } - if (metadata.iconRes != Resources.ID_NULL) { - setImageViewResource(R.id.icon, metadata.iconRes) - } else { - setViewVisibility(R.id.icon, View.GONE) - } - } -} - -@SuppressLint("RestrictedApi") -fun makeInlinePresentation( - context: Context, - imeSpec: InlinePresentationSpec, - metadata: DatasetMetadata -): InlinePresentation? { - if (Build.VERSION.SDK_INT < 30) return null - - if (UiVersions.INLINE_UI_VERSION_1 !in UiVersions.getVersions(imeSpec.style)) return null - - val launchIntent = - PendingIntent.getActivity( - context, - 0, - Intent(context, PasswordStore::class.java), - if (Build.VERSION.SDK_INT >= 31) { - PendingIntent.FLAG_MUTABLE - } else { - 0 - }, - ) - val slice = - InlineSuggestionUi.newContentBuilder(launchIntent).run { - setTitle(metadata.title) - if (metadata.subtitle != null) setSubtitle(metadata.subtitle) - setContentDescription( - if (metadata.subtitle != null) "${metadata.title} - ${metadata.subtitle}" - else metadata.title - ) - setStartIcon(Icon.createWithResource(context, metadata.iconRes)) - build().slice - } - - return InlinePresentation(slice, imeSpec, false) -} - -fun makeFillMatchMetadata(context: Context, file: File): DatasetMetadata { - val directoryStructure = AutofillPreferences.directoryStructure(context) - val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory()) - val title = - directoryStructure.getIdentifierFor(relativeFile) - ?: directoryStructure.getAccountPartFor(relativeFile)!! - val subtitle = directoryStructure.getAccountPartFor(relativeFile) - return DatasetMetadata(title, subtitle, R.drawable.ic_person_black_24dp) -} - -fun makeSearchAndFillMetadata(context: Context) = - DatasetMetadata( - context.getString(R.string.oreo_autofill_search_in_store), - null, - R.drawable.ic_search_black_24dp - ) - -fun makeGenerateAndFillMetadata(context: Context) = - DatasetMetadata( - context.getString(R.string.oreo_autofill_generate_password), - null, - R.drawable.ic_autofill_new_password - ) - -fun makeFillOtpFromSmsMetadata(context: Context) = - DatasetMetadata( - context.getString(R.string.oreo_autofill_fill_otp_from_sms), - null, - R.drawable.ic_autofill_sms - ) - -fun makeEmptyMetadata() = DatasetMetadata("PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher) - -fun makeWarningMetadata(context: Context) = - DatasetMetadata( - context.getString(R.string.oreo_autofill_warning_publisher_dataset_title), - context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary), - R.drawable.ic_warning_red_24dp - ) - -fun makeHeaderMetadata(title: String) = DatasetMetadata(title, null, 0) diff --git a/app/src/main/java/dev/msfjarvis/aps/util/crypto/GpgIdentifier.kt b/app/src/main/java/dev/msfjarvis/aps/util/crypto/GpgIdentifier.kt deleted file mode 100644 index f99b623c..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/crypto/GpgIdentifier.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.util.crypto - -import me.msfjarvis.openpgpktx.util.OpenPgpUtils - -sealed class GpgIdentifier { - data class KeyId(val id: Long) : GpgIdentifier() - data class UserId(val email: String) : GpgIdentifier() - - companion object { - @OptIn(ExperimentalUnsignedTypes::class) - fun fromString(identifier: String): GpgIdentifier? { - if (identifier.isEmpty()) return null - // Match long key IDs: - // FF22334455667788 or 0xFF22334455667788 - val maybeLongKeyId = - identifier.removePrefix("0x").takeIf { it.matches("[a-fA-F0-9]{16}".toRegex()) } - if (maybeLongKeyId != null) { - val keyId = maybeLongKeyId.toULong(16) - return KeyId(keyId.toLong()) - } - - // Match fingerprints: - // FF223344556677889900112233445566778899 or 0xFF223344556677889900112233445566778899 - val maybeFingerprint = - identifier.removePrefix("0x").takeIf { it.matches("[a-fA-F0-9]{40}".toRegex()) } - if (maybeFingerprint != null) { - // Truncating to the long key ID is not a security issue since OpenKeychain only - // accepts - // non-ambiguous key IDs. - val keyId = maybeFingerprint.takeLast(16).toULong(16) - return KeyId(keyId.toLong()) - } - - return OpenPgpUtils.splitUserId(identifier).email?.let { UserId(it) } - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt b/app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt deleted file mode 100644 index cc243a09..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.util.extensions - -import android.app.KeyguardManager -import android.content.ClipboardManager -import android.content.Context -import android.content.Intent -import android.content.SharedPreferences -import android.content.pm.PackageManager -import android.util.Base64 -import android.util.TypedValue -import android.view.View -import android.view.autofill.AutofillManager -import androidx.annotation.RequiresApi -import androidx.core.content.ContextCompat -import androidx.core.content.getSystemService -import androidx.fragment.app.FragmentActivity -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import com.github.michaelbull.result.Ok -import com.github.michaelbull.result.Result -import com.google.android.material.snackbar.Snackbar -import dev.msfjarvis.aps.BuildConfig -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.repo.PasswordRepository -import dev.msfjarvis.aps.util.git.operation.GitOperation -import logcat.logcat - -/** Get an instance of [AutofillManager]. Only available on Android Oreo and above */ -val Context.autofillManager: AutofillManager? - @RequiresApi(26) get() = getSystemService() - -/** Get an instance of [ClipboardManager] */ -val Context.clipboard - get() = getSystemService() - -/** Wrapper for [getEncryptedPrefs] to avoid open-coding the file name at each call site */ -fun Context.getEncryptedGitPrefs() = getEncryptedPrefs("git_operation") - -/** Get an instance of [EncryptedSharedPreferences] with the given [fileName] */ -private fun Context.getEncryptedPrefs(fileName: String): SharedPreferences { - val masterKeyAlias = - MasterKey.Builder(applicationContext).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() - return EncryptedSharedPreferences.create( - applicationContext, - fileName, - masterKeyAlias, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) -} - -/** Get an instance of [KeyguardManager] */ -val Context.keyguardManager: KeyguardManager - get() = getSystemService()!! - -/** Get the default [SharedPreferences] instance */ -val Context.sharedPrefs: SharedPreferences - get() = getSharedPreferences("${BuildConfig.APPLICATION_ID}_preferences", 0) - -/** Resolve [attr] from the [Context]'s theme */ -fun Context.resolveAttribute(attr: Int): Int { - val typedValue = TypedValue() - this.theme.resolveAttribute(attr, typedValue, true) - return typedValue.data -} - -/** - * Commit changes to the store from a [FragmentActivity] using a custom implementation of - * [GitOperation] - */ -suspend fun FragmentActivity.commitChange( - message: String, -): Result { - if (!PasswordRepository.isGitRepo()) { - return Ok(Unit) - } - return object : GitOperation(this@commitChange) { - override val commands = - arrayOf( - // Stage all files - git.add().addFilepattern("."), - // Populate the changed files count - git.status(), - // Commit everything! If anything changed, that is. - git.commit().setAll(true).setMessage(message), - ) - - override fun preExecute(): Boolean { - logcat { "Committing with message: '$message'" } - return true - } - } - .execute() -} - -/** Check if [permission] has been granted to the app. */ -fun FragmentActivity.isPermissionGranted(permission: String): Boolean { - return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED -} - -/** - * Show a [Snackbar] in a [FragmentActivity] and correctly anchor it to a - * [com.google.android.material.floatingactionbutton.FloatingActionButton] if one exists in the - * [view] - */ -fun FragmentActivity.snackbar( - view: View = findViewById(android.R.id.content), - message: String, - length: Int = Snackbar.LENGTH_SHORT, -): Snackbar { - val snackbar = Snackbar.make(view, message, length) - snackbar.anchorView = findViewById(R.id.fab) - snackbar.show() - return snackbar -} - -/** Launch an activity denoted by [clazz]. */ -fun FragmentActivity.launchActivity(clazz: Class) { - startActivity(Intent(this, clazz)) -} - -/** Simplifies the common `getString(key, null) ?: defaultValue` case slightly */ -fun SharedPreferences.getString(key: String): String? = getString(key, null) - -/** Convert this [String] to its [Base64] representation */ -fun String.base64(): String { - return Base64.encodeToString(encodeToByteArray(), Base64.NO_WRAP) -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt b/app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt deleted file mode 100644 index 8530a216..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.extensions - -import com.github.michaelbull.result.Err -import com.github.michaelbull.result.Ok -import com.github.michaelbull.result.Result -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching -import dev.msfjarvis.aps.data.repo.PasswordRepository -import java.io.File -import java.util.Date -import logcat.asLog -import org.eclipse.jgit.lib.ObjectId -import org.eclipse.jgit.revwalk.RevCommit - -/** The default OpenPGP provider for the app */ -const val OPENPGP_PROVIDER = "org.sufficientlysecure.keychain" - -/** Clears the given [flag] from the value of this [Int] */ -fun Int.clearFlag(flag: Int): Int { - return this and flag.inv() -} - -/** Checks if this [Int] contains the given [flag] */ -infix fun Int.hasFlag(flag: Int): Boolean { - return this and flag == flag -} - -/** Checks whether this [File] is a directory that contains [other]. */ -fun File.contains(other: File): Boolean { - if (!isDirectory) return false - if (!other.exists()) return false - val relativePath = - runCatching { other.relativeTo(this) } - .getOrElse { - return false - } - // Direct containment is equivalent to the relative path being equal to the filename. - return relativePath.path == other.name -} - -/** - * Checks if this [File] is in the password repository directory as given by - * [PasswordRepository.getRepositoryDirectory] - */ -fun File.isInsideRepository(): Boolean { - return canonicalPath.contains(PasswordRepository.getRepositoryDirectory().canonicalPath) -} - -/** Recursively lists the files in this [File], skipping any directories it encounters. */ -fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toList() - -/** - * Unique SHA-1 hash of this commit as hexadecimal string. - * - * @see RevCommit.getId - */ -val RevCommit.hash: String - get() = ObjectId.toString(id) - -/** - * Time this commit was made with second precision. - * - * @see RevCommit.commitTime - */ -val RevCommit.time: Date - get() { - val epochSeconds = commitTime.toLong() - val epochMilliseconds = epochSeconds * 1000 - return Date(epochMilliseconds) - } - -/** - * Splits this [String] into an [Array] of [String] s, split on the UNIX LF line ending and stripped - * of any empty lines. - */ -fun String.splitLines(): Array { - return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() -} - -/** Alias to [lazy] with thread safety mode always set to [LazyThreadSafetyMode.NONE]. */ -fun unsafeLazy(initializer: () -> T) = lazy(LazyThreadSafetyMode.NONE) { initializer.invoke() } - -/** A convenience extension to turn a [Throwable] with a message into a loggable string. */ -fun Throwable.asLog(message: String): String = "$message\n${asLog()}" - -/** Extension on [Result] that returns if the type is [Ok] */ -fun Result.isOk(): Boolean { - return this is Ok -} - -/** Extension on [Result] that returns if the type is [Err] */ -fun Result.isErr(): Boolean { - return this is Err -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt b/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt deleted file mode 100644 index 3a256ee7..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.util.extensions - -import androidx.annotation.IdRes -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.commit -import dev.msfjarvis.aps.R - -/** Check if [permission] is granted to the app. Aliases to [isPermissionGranted] internally. */ -fun Fragment.isPermissionGranted(permission: String): Boolean { - return requireActivity().isPermissionGranted(permission) -} - -/** Calls `finish()` on the enclosing [androidx.fragment.app.FragmentActivity] */ -fun Fragment.finish() = requireActivity().finish() - -/** - * Perform a [commit] on this [FragmentManager] with custom animations and adding the - * [destinationFragment] to the fragment backstack - */ -fun FragmentManager.performTransactionWithBackStack( - destinationFragment: Fragment, - @IdRes containerViewId: Int = android.R.id.content -) { - commit { - beginTransaction() - addToBackStack(destinationFragment.tag) - setCustomAnimations( - R.animator.slide_in_left, - R.animator.slide_out_left, - R.animator.slide_in_right, - R.animator.slide_out_right - ) - replace(containerViewId, destinationFragment) - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt b/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt deleted file mode 100644 index fe885c86..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.util.extensions - -import android.view.LayoutInflater -import android.view.View -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.viewbinding.ViewBinding -import kotlin.properties.ReadOnlyProperty -import kotlin.reflect.KProperty - -/** - * Imported from - * https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c - */ -class FragmentViewBindingDelegate( - val fragment: Fragment, - val viewBindingFactory: (View) -> T -) : ReadOnlyProperty { - - private var binding: T? = null - - init { - fragment.lifecycle.addObserver( - object : DefaultLifecycleObserver { - override fun onCreate(owner: LifecycleOwner) { - fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner -> - viewLifecycleOwner.lifecycle.addObserver( - object : DefaultLifecycleObserver { - override fun onDestroy(owner: LifecycleOwner) { - binding = null - } - } - ) - } - } - } - ) - } - - override fun getValue(thisRef: Fragment, property: KProperty<*>): T { - val binding = binding - if (binding != null) { - return binding - } - - val lifecycle = fragment.viewLifecycleOwner.lifecycle - if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) { - throw IllegalStateException( - "Should not attempt to get bindings when Fragment views are destroyed." - ) - } - - return viewBindingFactory(thisRef.requireView()).also { this.binding = it } - } -} - -fun Fragment.viewBinding(viewBindingFactory: (View) -> T) = - FragmentViewBindingDelegate(this, viewBindingFactory) - -inline fun AppCompatActivity.viewBinding( - crossinline bindingInflater: (LayoutInflater) -> T -) = unsafeLazy { bindingInflater.invoke(layoutInflater) } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/features/Feature.kt b/app/src/main/java/dev/msfjarvis/aps/util/features/Feature.kt deleted file mode 100644 index 4eef003d..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/features/Feature.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright © 2014-2022 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.util.features - -/** List of all feature flags for the app. */ -enum class Feature( - /** Default value for the flag. */ - val defaultValue: Boolean, - /** Key to retrieve the current value for the flag. */ - val configKey: String, -) { - - /** Opt into the new PGP backend powered by the PGPainless library. */ - EnablePGPainlessBackend(false, "enable_pgp_v2_backend"), - ; - - companion object { - @JvmField val VALUES = values() - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/features/Features.kt b/app/src/main/java/dev/msfjarvis/aps/util/features/Features.kt deleted file mode 100644 index 39b2c3a8..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/features/Features.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright © 2014-2022 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.util.features - -import android.content.SharedPreferences -import dev.msfjarvis.aps.injection.prefs.SettingsPreferences -import javax.inject.Inject - -class Features -@Inject -constructor( - @SettingsPreferences private val preferences: SharedPreferences, -) { - - fun isEnabled(feature: Feature): Boolean { - return preferences.getBoolean(feature.configKey, feature.defaultValue) - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt deleted file mode 100644 index 36dc445f..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.util.git - -import android.os.RemoteException -import androidx.annotation.StringRes -import dev.msfjarvis.aps.Application -import dev.msfjarvis.aps.R -import java.net.UnknownHostException - -/** - * Supertype for all Git-related [Exception] s that can be thrown by [GitCommandExecutor.execute]. - */ -sealed class GitException(@StringRes res: Int, vararg fmt: String) : - Exception(buildMessage(res, *fmt)) { - - override val message = super.message!! - - companion object { - - private fun buildMessage(@StringRes res: Int, vararg fmt: String) = - Application.instance.resources.getString(res, *fmt) - } - - /** Encapsulates possible errors from a [org.eclipse.jgit.api.PullCommand]. */ - sealed class PullException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) { - - object PullRebaseFailed : PullException(R.string.git_pull_rebase_fail_error) - object PullMergeFailed : PullException(R.string.git_pull_merge_fail_error) - } - - /** Encapsulates possible errors from a [org.eclipse.jgit.api.PushCommand]. */ - sealed class PushException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) { - - object NonFastForward : PushException(R.string.git_push_nff_error) - object RemoteRejected : PushException(R.string.git_push_other_error) - class Generic(message: String) : PushException(R.string.git_push_generic_error, message) - } -} - -object ErrorMessages { - - operator fun get(throwable: Throwable?): String { - val resources = Application.instance.resources - if (throwable == null) return resources.getString(R.string.git_unknown_error) - return when (val rootCause = rootCause(throwable)) { - is GitException -> rootCause.message - is UnknownHostException -> resources.getString(R.string.git_unknown_host, throwable.message) - else -> throwable.message ?: resources.getString(R.string.git_unknown_error) - } - } - - private fun rootCause(throwable: Throwable): Throwable { - var cause = throwable - while (cause.cause != null) { - if (cause is GitException) break - val nextCause = cause.cause!! - if (nextCause is RemoteException) break - cause = nextCause - } - return cause - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt deleted file mode 100644 index e6eb77f5..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.util.git - -import android.widget.Toast -import androidx.fragment.app.FragmentActivity -import com.github.michaelbull.result.Result -import com.github.michaelbull.result.runCatching -import com.google.android.material.snackbar.Snackbar -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.android.EntryPointAccessors -import dagger.hilt.components.SingletonComponent -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.util.extensions.snackbar -import dev.msfjarvis.aps.util.extensions.unsafeLazy -import dev.msfjarvis.aps.util.git.GitException.PullException -import dev.msfjarvis.aps.util.git.GitException.PushException -import dev.msfjarvis.aps.util.git.operation.GitOperation -import dev.msfjarvis.aps.util.settings.GitSettings -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.eclipse.jgit.api.CommitCommand -import org.eclipse.jgit.api.PullCommand -import org.eclipse.jgit.api.PushCommand -import org.eclipse.jgit.api.StatusCommand -import org.eclipse.jgit.lib.PersonIdent -import org.eclipse.jgit.transport.RemoteRefUpdate - -class GitCommandExecutor( - private val activity: FragmentActivity, - private val operation: GitOperation, -) { - - private val hiltEntryPoint by unsafeLazy { - EntryPointAccessors.fromApplication( - activity.applicationContext, - GitCommandExecutorEntryPoint::class.java - ) - } - suspend fun execute(): Result { - val gitSettings = hiltEntryPoint.gitSettings() - val snackbar = - activity.snackbar( - message = activity.resources.getString(R.string.git_operation_running), - length = Snackbar.LENGTH_INDEFINITE, - ) - // Count the number of uncommitted files - var nbChanges = 0 - return runCatching { - for (command in operation.commands) { - when (command) { - is StatusCommand -> { - val res = withContext(Dispatchers.IO) { command.call() } - nbChanges = res.uncommittedChanges.size - } - is CommitCommand -> { - // the previous status will eventually be used to avoid a commit - if (nbChanges > 0) { - withContext(Dispatchers.IO) { - val name = gitSettings.authorName.ifEmpty { "root" } - val email = gitSettings.authorEmail.ifEmpty { "localhost" } - val identity = PersonIdent(name, email) - command.setAuthor(identity).setCommitter(identity).call() - } - } - } - is PullCommand -> { - val result = withContext(Dispatchers.IO) { command.call() } - if (result.rebaseResult != null) { - if (!result.rebaseResult.status.isSuccessful) { - throw PullException.PullRebaseFailed - } - } else if (result.mergeResult != null) { - if (!result.mergeResult.mergeStatus.isSuccessful) { - throw PullException.PullMergeFailed - } - } - } - is PushCommand -> { - val results = withContext(Dispatchers.IO) { command.call() } - for (result in results) { - // Code imported (modified) from Gerrit PushOp, license Apache v2 - for (rru in result.remoteUpdates) { - when (rru.status) { - RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD -> - throw PushException.NonFastForward - RemoteRefUpdate.Status.REJECTED_NODELETE, - RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED, - RemoteRefUpdate.Status.NON_EXISTING, - RemoteRefUpdate.Status.NOT_ATTEMPTED, -> - throw PushException.Generic(rru.status.name) - RemoteRefUpdate.Status.REJECTED_OTHER_REASON -> { - throw if ("non-fast-forward" == rru.message) { - PushException.RemoteRejected - } else { - PushException.Generic(rru.message) - } - } - RemoteRefUpdate.Status.UP_TO_DATE -> { - withContext(Dispatchers.Main) { - Toast.makeText( - activity.applicationContext, - activity.applicationContext.getString(R.string.git_push_up_to_date), - Toast.LENGTH_SHORT - ) - .show() - } - } - else -> {} - } - } - } - } - else -> { - withContext(Dispatchers.IO) { command.call() } - } - } - } - } - .also { snackbar.dismiss() } - } - - @EntryPoint - @InstallIn(SingletonComponent::class) - interface GitCommandExecutorEntryPoint { - - fun gitSettings(): GitSettings - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommit.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommit.kt deleted file mode 100644 index 490f5e2c..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommit.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.util.git - -import java.util.Date - -/** - * Basic information about a git commit. - * - * @property hash full-length hash of the commit object. - * @property shortMessage the commit's short message (i.e. title line). - * @property authorName name of the commit's author without email address. - * @property time time when the commit was created. - */ -data class GitCommit( - val hash: String, - val shortMessage: String, - val authorName: String, - val time: Date -) diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt deleted file mode 100644 index ed210a65..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.util.git - -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching -import dev.msfjarvis.aps.data.repo.PasswordRepository -import dev.msfjarvis.aps.util.extensions.asLog -import dev.msfjarvis.aps.util.extensions.hash -import dev.msfjarvis.aps.util.extensions.time -import dev.msfjarvis.aps.util.extensions.unsafeLazy -import logcat.LogPriority.ERROR -import logcat.logcat -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.revwalk.RevCommit - -private val TAG = GitLogModel::class.java.simpleName - -private fun commits(): Iterable { - val repo = PasswordRepository.repository - if (repo == null) { - logcat(TAG, ERROR) { "Could not access git repository" } - return listOf() - } - return runCatching { Git(repo).log().call() } - .getOrElse { e -> - logcat(TAG, ERROR) { e.asLog("Failed to obtain git commits") } - listOf() - } -} - -/** - * Provides [GitCommit] s from a git-log of the password git repository. - * - * All commits are acquired on the first request to this object. - */ -class GitLogModel { - - // All commits are acquired here at once. Acquiring the commits in batches would not have been - // entirely sensible because the amount of computation required to obtain commit number n from - // the log includes the amount of computation required to obtain commit number n-1 from the log. - // This is because the commit graph is walked from HEAD to the last commit to obtain. - // Additionally, tests with 1000 commits in the log have not produced a significant delay in the - // user experience. - private val cache: MutableList by unsafeLazy { - commits() - .map { GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) } - .toMutableList() - } - val size = cache.size - - fun get(index: Int): GitCommit? { - if (index >= size) - logcat(ERROR) { "Cannot get git commit with index $index. There are only $size." } - return cache.getOrNull(index) - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt deleted file mode 100644 index 6ea9b8bb..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.git.operation - -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.util.extensions.unsafeLazy -import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity -import org.eclipse.jgit.api.RebaseCommand -import org.eclipse.jgit.api.ResetCommand -import org.eclipse.jgit.lib.RepositoryState - -class BreakOutOfDetached(callingActivity: ContinuationContainerActivity) : - GitOperation(callingActivity) { - - private val merging = repository.repositoryState == RepositoryState.MERGING - private val resetCommands = - arrayOf( - // git checkout -b conflict-branch - git - .checkout() - .setCreateBranch(true) - .setName("conflicting-$remoteBranch-${System.currentTimeMillis()}"), - // push the changes - git.push().setRemote("origin"), - // switch back to ${gitBranch} - git.checkout().setName(remoteBranch), - ) - - override val commands by unsafeLazy { - if (merging) { - // We need to run some non-command operations first - repository.writeMergeCommitMsg(null) - repository.writeMergeHeads(null) - arrayOf( - // reset hard back to our local HEAD - git.reset().setMode(ResetCommand.ResetType.HARD), - *resetCommands, - ) - } else { - arrayOf( - // abort the rebase - git.rebase().setOperation(RebaseCommand.Operation.ABORT), - *resetCommands, - ) - } - } - - override fun preExecute() = - if (!git.repository.repositoryState.isRebasing && !merging) { - MaterialAlertDialogBuilder(callingActivity) - .setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title)) - .setMessage( - callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded) - ) - .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> - callingActivity.finish() - } - .show() - false - } else { - true - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt deleted file mode 100644 index 75db2ea5..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.git.operation - -import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.api.GitCommand - -/** - * Creates a new clone operation - * - * @param uri URL to clone the repository from - * @param callingActivity the calling activity - */ -class CloneOperation(callingActivity: ContinuationContainerActivity, uri: String) : - GitOperation(callingActivity) { - - override val commands: Array> = - arrayOf( - Git.cloneRepository().setBranch(remoteBranch).setDirectory(repository.workTree).setURI(uri), - ) -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt deleted file mode 100644 index 623d69ea..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.util.git.operation - -import android.annotation.SuppressLint -import android.content.SharedPreferences -import android.view.LayoutInflater -import android.view.WindowManager -import androidx.annotation.StringRes -import androidx.core.content.edit -import androidx.core.widget.doOnTextChanged -import androidx.fragment.app.FragmentActivity -import com.google.android.material.checkbox.MaterialCheckBox -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.android.EntryPointAccessors -import dagger.hilt.components.SingletonComponent -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.injection.prefs.GitPreferences -import dev.msfjarvis.aps.util.git.sshj.InteractivePasswordFinder -import dev.msfjarvis.aps.util.settings.AuthMode -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import kotlin.coroutines.Continuation -import kotlin.coroutines.resume - -class CredentialFinder(val callingActivity: FragmentActivity, val authMode: AuthMode) : - InteractivePasswordFinder() { - - private val hiltEntryPoint = - EntryPointAccessors.fromApplication( - callingActivity.applicationContext, - CredentialFinderEntryPoint::class.java, - ) - - override fun askForPassword(cont: Continuation, isRetry: Boolean) { - val gitOperationPrefs = hiltEntryPoint.gitPrefs() - val credentialPref: String - @StringRes val messageRes: Int - @StringRes val hintRes: Int - @StringRes val rememberRes: Int - @StringRes val errorRes: Int - when (authMode) { - AuthMode.SshKey -> { - credentialPref = PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE - messageRes = R.string.passphrase_dialog_text - hintRes = R.string.ssh_keygen_passphrase - rememberRes = R.string.git_operation_remember_passphrase - errorRes = R.string.git_operation_wrong_passphrase - } - AuthMode.Password -> { - // Could be either an SSH or an HTTPS password - credentialPref = PreferenceKeys.HTTPS_PASSWORD - messageRes = R.string.password_dialog_text - hintRes = R.string.git_operation_hint_password - rememberRes = R.string.git_operation_remember_password - errorRes = R.string.git_operation_wrong_password - } - else -> - throw IllegalStateException("Only SshKey and Password connection mode ask for passwords") - } - if (isRetry) gitOperationPrefs.edit { remove(credentialPref) } - val storedCredential = gitOperationPrefs.getString(credentialPref, null) - if (storedCredential == null) { - val layoutInflater = LayoutInflater.from(callingActivity) - - @SuppressLint("InflateParams") - val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null) - val credentialLayout = - dialogView.findViewById(R.id.git_auth_passphrase_layout) - val editCredential = dialogView.findViewById(R.id.git_auth_credential) - editCredential.setHint(hintRes) - val rememberCredential = - dialogView.findViewById(R.id.git_auth_remember_credential) - rememberCredential.setText(rememberRes) - if (isRetry) { - credentialLayout.error = callingActivity.resources.getString(errorRes) - // Reset error when user starts entering a password - editCredential.doOnTextChanged { _, _, _, _ -> credentialLayout.error = null } - } - MaterialAlertDialogBuilder(callingActivity) - .run { - setTitle(R.string.passphrase_dialog_title) - setMessage(messageRes) - setView(dialogView) - setPositiveButton(R.string.dialog_ok) { _, _ -> - val credential = editCredential.text.toString() - if (rememberCredential.isChecked) { - gitOperationPrefs.edit { putString(credentialPref, credential) } - } - cont.resume(credential) - } - setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(null) } - setOnCancelListener { cont.resume(null) } - create() - } - .run { - window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - show() - } - } else { - cont.resume(storedCredential) - } - } - - @EntryPoint - @InstallIn(SingletonComponent::class) - interface CredentialFinderEntryPoint { - @GitPreferences fun gitPrefs(): SharedPreferences - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GcOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GcOperation.kt deleted file mode 100644 index c070027e..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GcOperation.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright © 2014-2022 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.util.git.operation - -import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity -import org.eclipse.jgit.api.GitCommand - -/** - * Run an aggressive garbage collection job on the repository, expiring every loose object to - * achieve the best compression. - */ -class GcOperation( - callingActivity: ContinuationContainerActivity, -) : GitOperation(callingActivity) { - - override val requiresAuth: Boolean = false - override val commands: Array> = - arrayOf(git.gc().setAggressive(true).setExpire(null)) -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt deleted file mode 100644 index 576a802d..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt +++ /dev/null @@ -1,245 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.git.operation - -import android.content.Intent -import android.widget.Toast -import androidx.fragment.app.FragmentActivity -import com.github.michaelbull.result.Err -import com.github.michaelbull.result.Ok -import com.github.michaelbull.result.Result -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.android.EntryPointAccessors -import dagger.hilt.components.SingletonComponent -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.repo.PasswordRepository -import dev.msfjarvis.aps.ui.sshkeygen.SshKeyGenActivity -import dev.msfjarvis.aps.ui.sshkeygen.SshKeyImportActivity -import dev.msfjarvis.aps.util.auth.BiometricAuthenticator -import dev.msfjarvis.aps.util.auth.BiometricAuthenticator.Result.* -import dev.msfjarvis.aps.util.git.GitCommandExecutor -import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity -import dev.msfjarvis.aps.util.git.sshj.SshAuthMethod -import dev.msfjarvis.aps.util.git.sshj.SshKey -import dev.msfjarvis.aps.util.git.sshj.SshjSessionFactory -import dev.msfjarvis.aps.util.settings.AuthMode -import dev.msfjarvis.aps.util.settings.GitSettings -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat -import net.schmizz.sshj.common.DisconnectReason -import net.schmizz.sshj.common.SSHException -import net.schmizz.sshj.userauth.password.PasswordFinder -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.api.GitCommand -import org.eclipse.jgit.api.TransportCommand -import org.eclipse.jgit.errors.UnsupportedCredentialItem -import org.eclipse.jgit.transport.CredentialItem -import org.eclipse.jgit.transport.CredentialsProvider -import org.eclipse.jgit.transport.SshTransport -import org.eclipse.jgit.transport.Transport -import org.eclipse.jgit.transport.URIish - -/** - * Creates a new git operation - * - * @param callingActivity the calling activity - */ -abstract class GitOperation(protected val callingActivity: FragmentActivity) { - - /** List of [GitCommand]s that are executed by an operation. */ - abstract val commands: Array> - - /** Whether the operation requires authentication or not. */ - open val requiresAuth: Boolean = true - private val hostKeyFile = callingActivity.filesDir.resolve(".host_key") - private var sshSessionFactory: SshjSessionFactory? = null - private val hiltEntryPoint = - EntryPointAccessors.fromApplication( - callingActivity.applicationContext, - GitOperationEntryPoint::class.java - ) - - protected val repository = PasswordRepository.repository!! - protected val git = Git(repository) - protected val remoteBranch = hiltEntryPoint.gitSettings().branch - private val authActivity - get() = callingActivity as ContinuationContainerActivity - - private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) : - CredentialsProvider() { - - private var cachedPassword: CharArray? = null - - override fun isInteractive() = true - - override fun get(uri: URIish?, vararg items: CredentialItem): Boolean { - for (item in items) { - when (item) { - is CredentialItem.Username -> item.value = uri?.user - is CredentialItem.Password -> { - item.value = - cachedPassword?.clone() - ?: passwordFinder.reqPassword(null).also { cachedPassword = it.clone() } - } - else -> UnsupportedCredentialItem(uri, item.javaClass.name) - } - } - return true - } - - override fun supports(vararg items: CredentialItem) = - items.all { it is CredentialItem.Username || it is CredentialItem.Password } - - override fun reset(uri: URIish?) { - cachedPassword?.fill(0.toChar()) - cachedPassword = null - } - } - - private fun getSshKey(make: Boolean) { - runCatching { - val intent = - if (make) { - Intent(callingActivity.applicationContext, SshKeyGenActivity::class.java) - } else { - Intent(callingActivity.applicationContext, SshKeyImportActivity::class.java) - } - callingActivity.startActivity(intent) - } - .onFailure { e -> logcat(ERROR) { e.asLog() } } - } - - private fun registerAuthProviders( - authMethod: SshAuthMethod, - credentialsProvider: CredentialsProvider? = null - ) { - sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile) - commands.filterIsInstance>().forEach { command -> - command.setTransportConfigCallback { transport: Transport -> - (transport as? SshTransport)?.sshSessionFactory = sshSessionFactory - credentialsProvider?.let { transport.credentialsProvider = it } - } - command.setTimeout(CONNECT_TIMEOUT) - } - } - - /** Executes the GitCommand in an async task. */ - suspend fun execute(): Result { - if (!preExecute()) { - return Ok(Unit) - } - val operationResult = - GitCommandExecutor( - callingActivity, - this, - ) - .execute() - postExecute() - return operationResult - } - - private fun onMissingSshKeyFile() { - MaterialAlertDialogBuilder(callingActivity) - .setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text)) - .setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title)) - .setPositiveButton( - callingActivity.resources.getString(R.string.ssh_preferences_dialog_import) - ) { _, _ -> getSshKey(false) } - .setNegativeButton( - callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate) - ) { _, _ -> getSshKey(true) } - .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ -> - // Finish the blank GitActivity so user doesn't have to press back - callingActivity.finish() - } - .show() - } - - suspend fun executeAfterAuthentication(authMode: AuthMode): Result { - when (authMode) { - AuthMode.SshKey -> - if (SshKey.exists) { - if (SshKey.mustAuthenticate) { - val result = - withContext(Dispatchers.Main) { - suspendCoroutine { cont -> - BiometricAuthenticator.authenticate( - callingActivity, - R.string.biometric_prompt_title_ssh_auth - ) { result -> if (result !is Failure) cont.resume(result) } - } - } - when (result) { - is Success -> { - registerAuthProviders(SshAuthMethod.SshKey(authActivity)) - } - is Cancelled -> { - return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)) - } - is Failure -> { - throw IllegalStateException("Biometric authentication failures should be ignored") - } - else -> { - // There is a chance we succeed if the user recently confirmed - // their screen lock. Doing so would have a potential to confuse - // users though, who might deduce that the screen lock - // protection is not effective. Hence, we fail with an error. - Toast.makeText( - callingActivity.applicationContext, - R.string.biometric_auth_generic_failure, - Toast.LENGTH_LONG - ) - .show() - callingActivity.finish() - } - } - } else { - registerAuthProviders(SshAuthMethod.SshKey(authActivity)) - } - } else { - onMissingSshKeyFile() - // This would correctly cancel the operation but won't surface a user-visible - // error, allowing users to make the SSH key selection. - return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)) - } - AuthMode.OpenKeychain -> registerAuthProviders(SshAuthMethod.OpenKeychain(authActivity)) - AuthMode.Password -> { - val httpsCredentialProvider = - HttpsCredentialsProvider(CredentialFinder(callingActivity, AuthMode.Password)) - registerAuthProviders(SshAuthMethod.Password(authActivity), httpsCredentialProvider) - } - AuthMode.None -> {} - } - return execute() - } - - /** Called before execution of the Git operation. Return false to cancel. */ - open fun preExecute() = true - - private suspend fun postExecute() { - withContext(Dispatchers.IO) { sshSessionFactory?.close() } - } - - companion object { - - /** Timeout in seconds before [TransportCommand] will abort a stalled IO operation. */ - private const val CONNECT_TIMEOUT = 10 - } - - @EntryPoint - @InstallIn(SingletonComponent::class) - interface GitOperationEntryPoint { - fun gitSettings(): GitSettings - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt deleted file mode 100644 index 394b7cb4..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.git.operation - -import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity -import org.eclipse.jgit.api.GitCommand - -class PullOperation( - callingActivity: ContinuationContainerActivity, - rebase: Boolean, -) : GitOperation(callingActivity) { - - /** - * The story of why the pull operation is committing files goes like this: Once upon a time when - * the world was burning and Blade Runner 2049 was real life (in the worst way), we were made - * aware that Bitbucket is actually bad, and disables a neat OpenSSH feature called multiplexing. - * So now, rather than being able to do a [SyncOperation], we'd have to first do a [PullOperation] - * and then a [PushOperation]. To make the behavior identical despite this suboptimal situation, - * we opted to replicate [SyncOperation]'s committing flow within [PullOperation], almost exactly - * replicating [SyncOperation] but leaving the pushing part to [PushOperation]. - */ - override val commands: Array> = - arrayOf( - // Stage all files - git.add().addFilepattern("."), - // Populate the changed files count - git.status(), - // Commit everything! If needed, obviously. - git.commit().setAll(true).setMessage("[Android Password Store] Sync"), - // Pull and rebase on top of the remote branch - git.pull().setRebase(rebase).setRemote("origin"), - ) -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt deleted file mode 100644 index 14f16164..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.git.operation - -import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity -import org.eclipse.jgit.api.GitCommand - -class PushOperation(callingActivity: ContinuationContainerActivity) : - GitOperation(callingActivity) { - - override val commands: Array> = - arrayOf( - git.push().setPushAll().setRemote("origin"), - ) -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt deleted file mode 100644 index 16114f65..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.git.operation - -import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity -import org.eclipse.jgit.api.ResetCommand - -class ResetToRemoteOperation(callingActivity: ContinuationContainerActivity) : - GitOperation(callingActivity) { - - override val commands = - arrayOf( - // Stage all files - git.add().addFilepattern("."), - // Fetch everything from the origin remote - git.fetch().setRemote("origin"), - // Do a hard reset to the remote branch. Equivalent to git reset --hard - // origin/$remoteBranch - git.reset().setRef("origin/$remoteBranch").setMode(ResetCommand.ResetType.HARD), - // Force-create $remoteBranch if it doesn't exist. This covers the case where you - // switched - // branches from 'master' to anything else. - git.branchCreate().setName(remoteBranch).setForce(true), - ) -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt deleted file mode 100644 index 589c6305..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.git.operation - -import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity - -class SyncOperation( - callingActivity: ContinuationContainerActivity, - rebase: Boolean, -) : GitOperation(callingActivity) { - - override val commands = - arrayOf( - // Stage all files - git.add().addFilepattern("."), - // Populate the changed files count - git.status(), - // Commit everything! If needed, obviously. - git.commit().setAll(true).setMessage("[Android Password Store] Sync"), - // Pull and rebase on top of the remote branch - git.pull().setRebase(rebase).setRemote("origin"), - // Push it all back - git.push().setPushAll().setRemote("origin"), - ) -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt deleted file mode 100644 index 523ff5b6..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.git.sshj - -import android.content.Intent -import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.LayoutRes -import androidx.appcompat.app.AppCompatActivity -import kotlin.coroutines.Continuation -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import net.schmizz.sshj.common.DisconnectReason -import net.schmizz.sshj.userauth.UserAuthException - -/** Workaround for https://msfjarvis.dev/aps/issue/1164 */ -open class ContinuationContainerActivity : AppCompatActivity { - - constructor() : super() - constructor(@LayoutRes layoutRes: Int) : super(layoutRes) - - var stashedCont: Continuation? = null - - val continueAfterUserInteraction = - registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> - stashedCont?.let { cont -> - stashedCont = null - val data = result.data - if (data != null) cont.resume(data) - else cont.resumeWithException(UserAuthException(DisconnectReason.AUTH_CANCELLED_BY_USER)) - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt deleted file mode 100644 index 8436d1ce..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt +++ /dev/null @@ -1,225 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.git.sshj - -import android.app.PendingIntent -import android.content.Intent -import androidx.activity.result.IntentSenderRequest -import androidx.core.content.edit -import androidx.lifecycle.lifecycleScope -import dev.msfjarvis.aps.util.extensions.OPENPGP_PROVIDER -import dev.msfjarvis.aps.util.extensions.sharedPrefs -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import java.io.Closeable -import java.security.PublicKey -import java.security.interfaces.ECKey -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.logcat -import net.schmizz.sshj.common.DisconnectReason -import net.schmizz.sshj.common.KeyType -import net.schmizz.sshj.userauth.UserAuthException -import net.schmizz.sshj.userauth.keyprovider.KeyProvider -import org.openintents.ssh.authentication.ISshAuthenticationService -import org.openintents.ssh.authentication.SshAuthenticationApi -import org.openintents.ssh.authentication.SshAuthenticationApiError -import org.openintents.ssh.authentication.SshAuthenticationConnection -import org.openintents.ssh.authentication.request.KeySelectionRequest -import org.openintents.ssh.authentication.request.Request -import org.openintents.ssh.authentication.request.SigningRequest -import org.openintents.ssh.authentication.request.SshPublicKeyRequest -import org.openintents.ssh.authentication.response.KeySelectionResponse -import org.openintents.ssh.authentication.response.Response -import org.openintents.ssh.authentication.response.SigningResponse -import org.openintents.ssh.authentication.response.SshPublicKeyResponse - -class OpenKeychainKeyProvider private constructor(val activity: ContinuationContainerActivity) : - KeyProvider, Closeable { - - companion object { - - suspend fun prepareAndUse( - activity: ContinuationContainerActivity, - block: (provider: OpenKeychainKeyProvider) -> Unit - ) { - withContext(Dispatchers.Main) { OpenKeychainKeyProvider(activity) }.prepareAndUse(block) - } - } - - private sealed class ApiResponse { - data class Success(val response: Response) : ApiResponse() - data class GeneralError(val exception: Exception) : ApiResponse() - data class NoSuchKey(val exception: Exception) : ApiResponse() - } - - private val context = activity.applicationContext - private val sshServiceConnection = SshAuthenticationConnection(context, OPENPGP_PROVIDER) - private val preferences = context.sharedPrefs - private lateinit var sshServiceApi: SshAuthenticationApi - - private var keyId - get() = preferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) - set(value) { - preferences.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, value) } - } - private var publicKey: PublicKey? = null - private var privateKey: OpenKeychainPrivateKey? = null - - private suspend fun prepareAndUse(block: (provider: OpenKeychainKeyProvider) -> Unit) { - prepare() - use(block) - } - - private suspend fun prepare() { - sshServiceApi = suspendCoroutine { cont -> - sshServiceConnection.connect( - object : SshAuthenticationConnection.OnBound { - override fun onBound(sshAgent: ISshAuthenticationService) { - logcat { "Bound to SshAuthenticationApi: $OPENPGP_PROVIDER" } - cont.resume(SshAuthenticationApi(context, sshAgent)) - } - - override fun onError() { - throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable") - } - } - ) - } - - if (keyId == null) { - selectKey() - } - check(keyId != null) - fetchPublicKey() - makePrivateKey() - } - - private suspend fun fetchPublicKey(isRetry: Boolean = false) { - when (val sshPublicKeyResponse = executeApiRequest(SshPublicKeyRequest(keyId))) { - is ApiResponse.Success -> { - val response = sshPublicKeyResponse.response as SshPublicKeyResponse - val sshPublicKey = response.sshPublicKey!! - publicKey = - parseSshPublicKey(sshPublicKey) - ?: throw IllegalStateException("OpenKeychain API returned invalid SSH key") - } - is ApiResponse.NoSuchKey -> - if (isRetry) { - throw sshPublicKeyResponse.exception - } else { - // Allow the user to reselect an authentication key and retry - selectKey() - fetchPublicKey(true) - } - is ApiResponse.GeneralError -> throw sshPublicKeyResponse.exception - } - } - - private suspend fun selectKey() { - when (val keySelectionResponse = executeApiRequest(KeySelectionRequest())) { - is ApiResponse.Success -> - keyId = (keySelectionResponse.response as KeySelectionResponse).keyId - is ApiResponse.GeneralError -> throw keySelectionResponse.exception - is ApiResponse.NoSuchKey -> throw keySelectionResponse.exception - } - } - - private suspend fun executeApiRequest( - request: Request, - resultOfUserInteraction: Intent? = null - ): ApiResponse { - logcat { "executeRequest($request) called" } - val result = - withContext(Dispatchers.Main) { - // If the request required user interaction, the data returned from the - // PendingIntent - // is used as the real request. - sshServiceApi.executeApi(resultOfUserInteraction ?: request.toIntent())!! - } - return parseResult(request, result).also { logcat { "executeRequest($request): $it" } } - } - - private suspend fun parseResult(request: Request, result: Intent): ApiResponse { - return when ( - result.getIntExtra( - SshAuthenticationApi.EXTRA_RESULT_CODE, - SshAuthenticationApi.RESULT_CODE_ERROR - ) - ) { - SshAuthenticationApi.RESULT_CODE_SUCCESS -> { - ApiResponse.Success( - when (request) { - is KeySelectionRequest -> KeySelectionResponse(result) - is SshPublicKeyRequest -> SshPublicKeyResponse(result) - is SigningRequest -> SigningResponse(result) - else -> throw IllegalArgumentException("Unsupported OpenKeychain request type") - } - ) - } - SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { - val pendingIntent: PendingIntent = - result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!! - val resultOfUserInteraction: Intent = - withContext(Dispatchers.Main) { - suspendCoroutine { cont -> - activity.stashedCont = cont - activity.continueAfterUserInteraction.launch( - IntentSenderRequest.Builder(pendingIntent).build() - ) - } - } - executeApiRequest(request, resultOfUserInteraction) - } - else -> { - val error = - result.getParcelableExtra(SshAuthenticationApi.EXTRA_ERROR) - val exception = - UserAuthException( - DisconnectReason.UNKNOWN, - "Request ${request::class.simpleName} failed: ${error?.message}" - ) - when (error?.error) { - SshAuthenticationApiError.NO_AUTH_KEY, - SshAuthenticationApiError.NO_SUCH_KEY -> ApiResponse.NoSuchKey(exception) - else -> ApiResponse.GeneralError(exception) - } - } - } - } - - private fun makePrivateKey() { - check(keyId != null && publicKey != null) - privateKey = - object : OpenKeychainPrivateKey { - override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) = - when ( - val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm)) - ) { - is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature - is ApiResponse.GeneralError -> throw signingResponse.exception - is ApiResponse.NoSuchKey -> throw signingResponse.exception - } - - override fun getAlgorithm() = publicKey!!.algorithm - override fun getParams() = (publicKey as? ECKey)?.params - } - } - - override fun close() { - activity.lifecycleScope.launch { - withContext(Dispatchers.Main) { activity.continueAfterUserInteraction.unregister() } - } - sshServiceConnection.disconnect() - } - - override fun getPrivate() = privateKey - - override fun getPublic() = publicKey - - override fun getType(): KeyType = KeyType.fromKey(publicKey) -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt deleted file mode 100644 index c5cb6eaa..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.git.sshj - -import com.hierynomus.sshj.key.KeyAlgorithm -import java.io.ByteArrayOutputStream -import java.security.PrivateKey -import java.security.interfaces.ECKey -import kotlinx.coroutines.runBlocking -import net.schmizz.sshj.common.Buffer -import net.schmizz.sshj.common.Factory -import net.schmizz.sshj.signature.Signature -import org.openintents.ssh.authentication.SshAuthenticationApi - -interface OpenKeychainPrivateKey : PrivateKey, ECKey { - - suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray - - override fun getFormat() = null - override fun getEncoded() = null -} - -class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named) : - Factory.Named by factory { - - override fun create() = OpenKeychainWrappedKeyAlgorithm(factory.create()) -} - -class OpenKeychainWrappedKeyAlgorithm(private val keyAlgorithm: KeyAlgorithm) : - KeyAlgorithm by keyAlgorithm { - - private val hashAlgorithm = - when (keyAlgorithm.keyAlgorithm) { - "rsa-sha2-512" -> SshAuthenticationApi.SHA512 - "rsa-sha2-256" -> SshAuthenticationApi.SHA256 - "ssh-rsa", - "ssh-rsa-cert-v01@openssh.com" -> SshAuthenticationApi.SHA1 - // Other algorithms don't use this value, but it has to be valid. - else -> SshAuthenticationApi.SHA512 - } - - override fun newSignature() = - OpenKeychainWrappedSignature(keyAlgorithm.newSignature(), hashAlgorithm) -} - -class OpenKeychainWrappedSignature( - private val wrappedSignature: Signature, - private val hashAlgorithm: Int -) : Signature by wrappedSignature { - - private val data = ByteArrayOutputStream() - - private var bridgedPrivateKey: OpenKeychainPrivateKey? = null - - override fun initSign(prvkey: PrivateKey?) { - if (prvkey is OpenKeychainPrivateKey) { - bridgedPrivateKey = prvkey - } else { - wrappedSignature.initSign(prvkey) - } - } - - override fun update(H: ByteArray?) { - if (bridgedPrivateKey != null) { - data.write(H!!) - } else { - wrappedSignature.update(H) - } - } - - override fun update(H: ByteArray?, off: Int, len: Int) { - if (bridgedPrivateKey != null) { - data.write(H!!, off, len) - } else { - wrappedSignature.update(H, off, len) - } - } - - override fun sign(): ByteArray? = - if (bridgedPrivateKey != null) { - runBlocking { bridgedPrivateKey!!.sign(data.toByteArray(), hashAlgorithm) } - } else { - wrappedSignature.sign() - } - - override fun encode(signature: ByteArray?): ByteArray? = - if (bridgedPrivateKey != null) { - require(signature != null) { "OpenKeychain signature must not be null" } - val encodedSignature = Buffer.PlainBuffer(signature) - // We need to drop the algorithm name and extract the raw signature since SSHJ adds the - // name - // later. - encodedSignature.readString() - encodedSignature.readBytes().also { - bridgedPrivateKey = null - data.reset() - } - } else { - wrappedSignature.encode(signature) - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt deleted file mode 100644 index ea96af53..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt +++ /dev/null @@ -1,372 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.git.sshj - -import android.content.Context -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Build -import android.provider.OpenableColumns -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyInfo -import android.security.keystore.KeyProperties -import android.util.Base64 -import androidx.core.content.edit -import androidx.security.crypto.EncryptedFile -import androidx.security.crypto.MasterKey -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching -import dev.msfjarvis.aps.Application -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs -import dev.msfjarvis.aps.util.extensions.getString -import dev.msfjarvis.aps.util.extensions.sharedPrefs -import dev.msfjarvis.aps.util.extensions.unsafeLazy -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import java.io.File -import java.io.IOException -import java.security.KeyFactory -import java.security.KeyPairGenerator -import java.security.KeyStore -import java.security.PrivateKey -import java.security.PublicKey -import javax.crypto.SecretKey -import javax.crypto.SecretKeyFactory -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import logcat.asLog -import logcat.logcat -import net.i2p.crypto.eddsa.EdDSAPrivateKey -import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable -import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec -import net.schmizz.sshj.SSHClient -import net.schmizz.sshj.common.Buffer -import net.schmizz.sshj.common.KeyType -import net.schmizz.sshj.userauth.keyprovider.KeyProvider - -private const val PROVIDER_ANDROID_KEY_STORE = "AndroidKeyStore" -private const val KEYSTORE_ALIAS = "sshkey" -private const val ANDROIDX_SECURITY_KEYSET_PREF_NAME = "androidx_sshkey_keyset_prefs" - -private val androidKeystore: KeyStore by unsafeLazy { - KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) } -} - -private val KeyStore.sshPrivateKey - get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey - -private val KeyStore.sshPublicKey - get() = getCertificate(KEYSTORE_ALIAS)?.publicKey - -fun parseSshPublicKey(sshPublicKey: String): PublicKey? { - val sshKeyParts = sshPublicKey.split("""\s+""".toRegex()) - if (sshKeyParts.size < 2) return null - return Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey() -} - -fun toSshPublicKey(publicKey: PublicKey): String { - val rawPublicKey = Buffer.PlainBuffer().putPublicKey(publicKey).compactData - val keyType = KeyType.fromKey(publicKey) - return "$keyType ${Base64.encodeToString(rawPublicKey, Base64.NO_WRAP)}" -} - -object SshKey { - - val sshPublicKey - get() = if (publicKeyFile.exists()) publicKeyFile.readText() else null - val canShowSshPublicKey - get() = type in listOf(Type.LegacyGenerated, Type.KeystoreNative, Type.KeystoreWrappedEd25519) - val exists - get() = type != null - val mustAuthenticate: Boolean - get() { - return runCatching { - if (type !in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519)) return false - when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) { - is PrivateKey -> { - val factory = KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE) - return factory.getKeySpec(key, KeyInfo::class.java).isUserAuthenticationRequired - } - is SecretKey -> { - val factory = SecretKeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE) - (factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo).isUserAuthenticationRequired - } - else -> throw IllegalStateException("SSH key does not exist in Keystore") - } - } - .getOrElse { error -> - // It is fine to swallow the exception here since it will reappear when the key - // is - // used for SSH authentication and can then be shown in the UI. - logcat { error.asLog() } - false - } - } - - private val context: Context - get() = Application.instance.applicationContext - - private val privateKeyFile - get() = File(context.filesDir, ".ssh_key") - private val publicKeyFile - get() = File(context.filesDir, ".ssh_key.pub") - - private var type: Type? - get() = Type.fromValue(context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_KEY_TYPE)) - set(value) = - context.sharedPrefs.edit { putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value) } - - private val isStrongBoxSupported by unsafeLazy { - if (Build.VERSION.SDK_INT >= 28) - context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) - else false - } - - private enum class Type(val value: String) { - Imported("imported"), - KeystoreNative("keystore_native"), - KeystoreWrappedEd25519("keystore_wrapped_ed25519"), - - // Behaves like `Imported`, but allows to view the public key. - LegacyGenerated("legacy_generated"), - ; - - companion object { - - fun fromValue(value: String?): Type? = values().associateBy { it.value }[value] - } - } - - enum class Algorithm( - val algorithm: String, - val applyToSpec: KeyGenParameterSpec.Builder.() -> Unit - ) { - Rsa( - KeyProperties.KEY_ALGORITHM_RSA, - { - setKeySize(3072) - setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1) - setDigests( - KeyProperties.DIGEST_SHA1, - KeyProperties.DIGEST_SHA256, - KeyProperties.DIGEST_SHA512 - ) - } - ), - Ecdsa( - KeyProperties.KEY_ALGORITHM_EC, - { - setKeySize(256) - setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1")) - setDigests(KeyProperties.DIGEST_SHA256) - if (Build.VERSION.SDK_INT >= 28) { - setIsStrongBoxBacked(isStrongBoxSupported) - } - } - ), - } - - private fun delete() { - androidKeystore.deleteEntry(KEYSTORE_ALIAS) - // Remove Tink key set used by AndroidX's EncryptedFile. - context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE).edit { - clear() - } - if (privateKeyFile.isFile) { - privateKeyFile.delete() - } - if (publicKeyFile.isFile) { - publicKeyFile.delete() - } - context.getEncryptedGitPrefs().edit { remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) } - type = null - } - - fun import(uri: Uri) { - // First check whether the content at uri is likely an SSH private key. - val fileSize = - context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { - cursor -> - // Cursor returns only a single row. - cursor.moveToFirst() - cursor.getInt(0) - } - ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) - - // We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes. - if (fileSize > 100_000 || fileSize == 0) - throw IllegalArgumentException( - context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message) - ) - - val sshKeyInputStream = - context.contentResolver.openInputStream(uri) - ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) - val lines = sshKeyInputStream.bufferedReader().readLines() - - // The file must have more than 2 lines, and the first and last line must have private key - // markers. - if ( - lines.size < 2 || - !Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) || - !Regex("END .* PRIVATE KEY").containsMatchIn(lines.last()) - ) - throw IllegalArgumentException( - context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message) - ) - - // At this point, we are reasonably confident that we have actually been provided a private - // key and delete the old key. - delete() - // Canonicalize line endings to '\n'. - privateKeyFile.writeText(lines.joinToString("\n")) - - type = Type.Imported - } - - @Deprecated("To be used only in Migrations.kt") - fun useLegacyKey(isGenerated: Boolean) { - type = if (isGenerated) Type.LegacyGenerated else Type.Imported - } - - @Suppress("BlockingMethodInNonBlockingContext") - private suspend fun getOrCreateWrappingMasterKey(requireAuthentication: Boolean) = - withContext(Dispatchers.IO) { - MasterKey.Builder(context, KEYSTORE_ALIAS).run { - setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - setRequestStrongBoxBacked(true) - setUserAuthenticationRequired(requireAuthentication, 15) - build() - } - } - - @Suppress("BlockingMethodInNonBlockingContext") - private suspend fun getOrCreateWrappedPrivateKeyFile(requireAuthentication: Boolean) = - withContext(Dispatchers.IO) { - EncryptedFile.Builder( - context, - privateKeyFile, - getOrCreateWrappingMasterKey(requireAuthentication), - EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB - ) - .run { - setKeysetPrefName(ANDROIDX_SECURITY_KEYSET_PREF_NAME) - build() - } - } - - @Suppress("BlockingMethodInNonBlockingContext") - suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) = - withContext(Dispatchers.IO) { - delete() - - val encryptedPrivateKeyFile = getOrCreateWrappedPrivateKeyFile(requireAuthentication) - // Generate the ed25519 key pair and encrypt the private key. - val keyPair = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair() - encryptedPrivateKeyFile.openFileOutput().use { os -> - os.write((keyPair.private as EdDSAPrivateKey).seed) - } - - // Write public key in SSH format to .ssh_key.pub. - publicKeyFile.writeText(toSshPublicKey(keyPair.public)) - - type = Type.KeystoreWrappedEd25519 - } - - fun generateKeystoreNativeKey(algorithm: Algorithm, requireAuthentication: Boolean) { - delete() - - // Generate Keystore-backed private key. - val parameterSpec = - KeyGenParameterSpec.Builder(KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN).run { - apply(algorithm.applyToSpec) - if (requireAuthentication) { - setUserAuthenticationRequired(true) - if (Build.VERSION.SDK_INT >= 30) { - setUserAuthenticationParameters(30, KeyProperties.AUTH_DEVICE_CREDENTIAL) - } else { - @Suppress("DEPRECATION") setUserAuthenticationValidityDurationSeconds(30) - } - } - build() - } - val keyPair = - KeyPairGenerator.getInstance(algorithm.algorithm, PROVIDER_ANDROID_KEY_STORE).run { - initialize(parameterSpec) - generateKeyPair() - } - - // Write public key in SSH format to .ssh_key.pub. - publicKeyFile.writeText(toSshPublicKey(keyPair.public)) - - type = Type.KeystoreNative - } - - fun provide(client: SSHClient, passphraseFinder: InteractivePasswordFinder): KeyProvider? = - when (type) { - Type.LegacyGenerated, - Type.Imported -> client.loadKeys(privateKeyFile.absolutePath, passphraseFinder) - Type.KeystoreNative -> KeystoreNativeKeyProvider - Type.KeystoreWrappedEd25519 -> KeystoreWrappedEd25519KeyProvider - null -> null - } - - private object KeystoreNativeKeyProvider : KeyProvider { - - override fun getPublic(): PublicKey = - runCatching { androidKeystore.sshPublicKey!! } - .getOrElse { error -> - logcat { error.asLog() } - throw IOException( - "Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", - error - ) - } - - override fun getPrivate(): PrivateKey = - runCatching { androidKeystore.sshPrivateKey!! } - .getOrElse { error -> - logcat { error.asLog() } - throw IOException( - "Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore", - error - ) - } - - override fun getType(): KeyType = KeyType.fromKey(public) - } - - private object KeystoreWrappedEd25519KeyProvider : KeyProvider { - - override fun getPublic(): PublicKey = - runCatching { parseSshPublicKey(sshPublicKey!!)!! } - .getOrElse { error -> - logcat { error.asLog() } - throw IOException("Failed to get the public key for wrapped ed25519 key", error) - } - - override fun getPrivate(): PrivateKey = - runCatching { - // The current MasterKey API does not allow getting a reference to an existing - // one - // without specifying the KeySpec for a new one. However, the value for passed - // here - // for `requireAuthentication` is not used as the key already exists at this - // point. - val encryptedPrivateKeyFile = runBlocking { getOrCreateWrappedPrivateKeyFile(false) } - val rawPrivateKey = encryptedPrivateKeyFile.openFileInput().use { it.readBytes() } - EdDSAPrivateKey( - EdDSAPrivateKeySpec(rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC) - ) - } - .getOrElse { error -> - logcat { error.asLog() } - throw IOException("Failed to unwrap wrapped ed25519 key", error) - } - - override fun getType(): KeyType = KeyType.fromKey(public) - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt deleted file mode 100644 index 948fbd35..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt +++ /dev/null @@ -1,289 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.git.sshj - -import com.github.michaelbull.result.runCatching -import com.hierynomus.sshj.key.KeyAlgorithms -import com.hierynomus.sshj.transport.cipher.BlockCiphers -import com.hierynomus.sshj.transport.cipher.GcmCiphers -import com.hierynomus.sshj.transport.kex.ExtInfoClientFactory -import com.hierynomus.sshj.transport.mac.Macs -import com.hierynomus.sshj.userauth.keyprovider.OpenSSHKeyV1KeyFile -import java.security.Security -import logcat.LogPriority.ERROR -import logcat.LogPriority.INFO -import logcat.LogPriority.VERBOSE -import logcat.LogPriority.WARN -import logcat.asLog -import logcat.logcat -import net.schmizz.keepalive.KeepAliveProvider -import net.schmizz.sshj.ConfigImpl -import net.schmizz.sshj.common.LoggerFactory -import net.schmizz.sshj.common.SecurityUtils -import net.schmizz.sshj.transport.compression.NoneCompression -import net.schmizz.sshj.transport.kex.Curve25519SHA256 -import net.schmizz.sshj.transport.kex.Curve25519SHA256.FactoryLibSsh -import net.schmizz.sshj.transport.kex.DHGexSHA256 -import net.schmizz.sshj.transport.kex.ECDHNistP -import net.schmizz.sshj.transport.random.JCERandom -import net.schmizz.sshj.transport.random.SingletonRandomFactory -import net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile -import net.schmizz.sshj.userauth.keyprovider.PKCS5KeyFile -import net.schmizz.sshj.userauth.keyprovider.PKCS8KeyFile -import net.schmizz.sshj.userauth.keyprovider.PuTTYKeyFile -import org.bouncycastle.jce.provider.BouncyCastleProvider -import org.slf4j.Logger -import org.slf4j.Marker - -fun setUpBouncyCastleForSshj() { - // Replace the Android BC provider with the Java BouncyCastle provider since the former does - // not include all the required algorithms. - // Note: This may affect crypto operations in other parts of the application. - val bcIndex = - Security.getProviders().indexOfFirst { it.name == BouncyCastleProvider.PROVIDER_NAME } - if (bcIndex == -1) { - // No Android BC found, install Java BC at lowest priority. - Security.addProvider(BouncyCastleProvider()) - } else { - // Replace Android BC with Java BC, inserted at the same position. - Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) - // May be needed on Android Pie+ as per https://stackoverflow.com/a/57897224/297261 - runCatching { Class.forName("sun.security.jca.Providers") } - Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1) - } - logcat("setUpBouncyCastleForSshj") { - "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" - } - // Prevent sshj from forwarding all cryptographic operations to BC. - SecurityUtils.setRegisterBouncyCastle(false) - SecurityUtils.setSecurityProvider(null) -} - -private abstract class AbstractLogger(private val name: String) : Logger { - - abstract fun t(message: String, t: Throwable? = null, vararg args: Any?) - abstract fun d(message: String, t: Throwable? = null, vararg args: Any?) - abstract fun i(message: String, t: Throwable? = null, vararg args: Any?) - abstract fun w(message: String, t: Throwable? = null, vararg args: Any?) - abstract fun e(message: String, t: Throwable? = null, vararg args: Any?) - - override fun getName() = name - - override fun isTraceEnabled(marker: Marker?): Boolean = isTraceEnabled - override fun isDebugEnabled(marker: Marker?): Boolean = isDebugEnabled - override fun isInfoEnabled(marker: Marker?): Boolean = isInfoEnabled - override fun isWarnEnabled(marker: Marker?): Boolean = isWarnEnabled - override fun isErrorEnabled(marker: Marker?): Boolean = isErrorEnabled - - override fun trace(msg: String) = t(msg) - override fun trace(format: String, arg: Any?) = t(format, null, arg) - override fun trace(format: String, arg1: Any?, arg2: Any?) = t(format, null, arg1, arg2) - override fun trace(format: String, vararg arguments: Any?) = t(format, null, *arguments) - override fun trace(msg: String, t: Throwable?) = t(msg, t) - override fun trace(marker: Marker, msg: String) = trace(msg) - override fun trace(marker: Marker?, format: String, arg: Any?) = trace(format, arg) - override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = - trace(format, arg1, arg2) - - override fun trace(marker: Marker?, format: String, vararg arguments: Any?) = - trace(format, *arguments) - - override fun trace(marker: Marker?, msg: String, t: Throwable?) = trace(msg, t) - - override fun debug(msg: String) = d(msg) - override fun debug(format: String, arg: Any?) = d(format, null, arg) - override fun debug(format: String, arg1: Any?, arg2: Any?) = d(format, null, arg1, arg2) - override fun debug(format: String, vararg arguments: Any?) = d(format, null, *arguments) - override fun debug(msg: String, t: Throwable?) = d(msg, t) - override fun debug(marker: Marker, msg: String) = debug(msg) - override fun debug(marker: Marker?, format: String, arg: Any?) = debug(format, arg) - override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = - debug(format, arg1, arg2) - - override fun debug(marker: Marker?, format: String, vararg arguments: Any?) = - debug(format, *arguments) - - override fun debug(marker: Marker?, msg: String, t: Throwable?) = debug(msg, t) - - override fun info(msg: String) = i(msg) - override fun info(format: String, arg: Any?) = i(format, null, arg) - override fun info(format: String, arg1: Any?, arg2: Any?) = i(format, null, arg1, arg2) - override fun info(format: String, vararg arguments: Any?) = i(format, null, *arguments) - override fun info(msg: String, t: Throwable?) = i(msg, t) - override fun info(marker: Marker, msg: String) = info(msg) - override fun info(marker: Marker?, format: String, arg: Any?) = info(format, arg) - override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = - info(format, arg1, arg2) - - override fun info(marker: Marker?, format: String, vararg arguments: Any?) = - info(format, *arguments) - - override fun info(marker: Marker?, msg: String, t: Throwable?) = info(msg, t) - - override fun warn(msg: String) = w(msg) - override fun warn(format: String, arg: Any?) = w(format, null, arg) - override fun warn(format: String, arg1: Any?, arg2: Any?) = w(format, null, arg1, arg2) - override fun warn(format: String, vararg arguments: Any?) = w(format, null, *arguments) - override fun warn(msg: String, t: Throwable?) = w(msg, t) - override fun warn(marker: Marker, msg: String) = warn(msg) - override fun warn(marker: Marker?, format: String, arg: Any?) = warn(format, arg) - override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = - warn(format, arg1, arg2) - - override fun warn(marker: Marker?, format: String, vararg arguments: Any?) = - warn(format, *arguments) - - override fun warn(marker: Marker?, msg: String, t: Throwable?) = warn(msg, t) - - override fun error(msg: String) = e(msg) - override fun error(format: String, arg: Any?) = e(format, null, arg) - override fun error(format: String, arg1: Any?, arg2: Any?) = e(format, null, arg1, arg2) - override fun error(format: String, vararg arguments: Any?) = e(format, null, *arguments) - override fun error(msg: String, t: Throwable?) = e(msg, t) - override fun error(marker: Marker, msg: String) = error(msg) - override fun error(marker: Marker?, format: String, arg: Any?) = error(format, arg) - override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = - error(format, arg1, arg2) - - override fun error(marker: Marker?, format: String, vararg arguments: Any?) = - error(format, *arguments) - - override fun error(marker: Marker?, msg: String, t: Throwable?) = error(msg, t) -} - -object LogcatLoggerFactory : LoggerFactory { - private class LogcatLogger(name: String) : AbstractLogger(name) { - - override fun isTraceEnabled() = true - override fun isDebugEnabled() = true - override fun isInfoEnabled() = true - override fun isWarnEnabled() = true - override fun isErrorEnabled() = true - - // Replace slf4j's "{}" format string style with standard Java's "%s". - // The supposedly redundant escape on the } is not redundant. - @Suppress("RegExpRedundantEscape") - private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s") - - override fun t(message: String, t: Throwable?, vararg args: Any?) { - logcat(name, VERBOSE) { message.fix().format(*args) + (t?.asLog() ?: "") } - } - - override fun d(message: String, t: Throwable?, vararg args: Any?) { - logcat(name) { message.fix().format(*args) + (t?.asLog() ?: "") } - } - - override fun i(message: String, t: Throwable?, vararg args: Any?) { - logcat(name, INFO) { message.fix().format(*args) + (t?.asLog() ?: "") } - } - - override fun w(message: String, t: Throwable?, vararg args: Any?) { - logcat(name, WARN) { message.fix().format(*args) + (t?.asLog() ?: "") } - } - - override fun e(message: String, t: Throwable?, vararg args: Any?) { - logcat(name, ERROR) { message.fix().format(*args) + (t?.asLog() ?: "") } - } - } - - override fun getLogger(name: String): Logger { - return LogcatLogger(name) - } - - override fun getLogger(clazz: Class<*>): Logger { - return LogcatLogger(clazz.name) - } -} - -class SshjConfig : ConfigImpl() { - - init { - loggerFactory = LogcatLoggerFactory - keepAliveProvider = KeepAliveProvider.HEARTBEAT - version = "OpenSSH_8.2p1 Ubuntu-4ubuntu0.1" - - initKeyExchangeFactories() - initKeyAlgorithms() - initRandomFactory() - initFileKeyProviderFactories() - initCipherFactories() - initCompressionFactories() - initMACFactories() - } - - private fun initKeyExchangeFactories() { - keyExchangeFactories = - listOf( - Curve25519SHA256.Factory(), - FactoryLibSsh(), - ECDHNistP.Factory521(), - ECDHNistP.Factory384(), - ECDHNistP.Factory256(), - DHGexSHA256.Factory(), - // Sends "ext-info-c" with the list of key exchange algorithms. This is needed to - // get - // rsa-sha2-* key types to work with some servers (e.g. GitHub). - ExtInfoClientFactory(), - ) - } - - private fun initKeyAlgorithms() { - keyAlgorithms = - listOf( - KeyAlgorithms.SSHRSACertV01(), - KeyAlgorithms.EdDSA25519(), - KeyAlgorithms.ECDSASHANistp521(), - KeyAlgorithms.ECDSASHANistp384(), - KeyAlgorithms.ECDSASHANistp256(), - KeyAlgorithms.RSASHA512(), - KeyAlgorithms.RSASHA256(), - KeyAlgorithms.SSHRSA(), - ) - .map { OpenKeychainWrappedKeyAlgorithmFactory(it) } - } - - private fun initRandomFactory() { - randomFactory = SingletonRandomFactory(JCERandom.Factory()) - } - - private fun initFileKeyProviderFactories() { - fileKeyProviderFactories = - listOf( - OpenSSHKeyV1KeyFile.Factory(), - PKCS8KeyFile.Factory(), - PKCS5KeyFile.Factory(), - OpenSSHKeyFile.Factory(), - PuTTYKeyFile.Factory(), - ) - } - - private fun initCipherFactories() { - cipherFactories = - listOf( - GcmCiphers.AES128GCM(), - GcmCiphers.AES256GCM(), - BlockCiphers.AES256CTR(), - BlockCiphers.AES192CTR(), - BlockCiphers.AES128CTR(), - ) - } - - private fun initMACFactories() { - macFactories = - listOf( - Macs.HMACSHA2512Etm(), - Macs.HMACSHA2256Etm(), - Macs.HMACSHA2512(), - Macs.HMACSHA2256(), - ) - } - - private fun initCompressionFactories() { - compressionFactories = - listOf( - NoneCompression.Factory(), - ) - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt deleted file mode 100644 index a9f84fa9..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt +++ /dev/null @@ -1,218 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.git.sshj - -import android.util.Base64 -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching -import dev.msfjarvis.aps.util.git.operation.CredentialFinder -import dev.msfjarvis.aps.util.settings.AuthMode -import java.io.File -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import java.security.PublicKey -import java.util.Collections -import java.util.concurrent.TimeUnit -import kotlin.coroutines.Continuation -import kotlin.coroutines.suspendCoroutine -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import logcat.LogPriority.WARN -import logcat.logcat -import net.schmizz.sshj.SSHClient -import net.schmizz.sshj.common.Buffer.PlainBuffer -import net.schmizz.sshj.common.DisconnectReason -import net.schmizz.sshj.common.SSHException -import net.schmizz.sshj.common.SSHRuntimeException -import net.schmizz.sshj.common.SecurityUtils -import net.schmizz.sshj.connection.channel.direct.Session -import net.schmizz.sshj.transport.verification.FingerprintVerifier -import net.schmizz.sshj.transport.verification.HostKeyVerifier -import net.schmizz.sshj.userauth.method.AuthPassword -import net.schmizz.sshj.userauth.method.AuthPublickey -import net.schmizz.sshj.userauth.password.PasswordFinder -import net.schmizz.sshj.userauth.password.Resource -import org.eclipse.jgit.transport.CredentialsProvider -import org.eclipse.jgit.transport.RemoteSession -import org.eclipse.jgit.transport.SshSessionFactory -import org.eclipse.jgit.transport.URIish -import org.eclipse.jgit.util.FS - -sealed class SshAuthMethod(val activity: ContinuationContainerActivity) { - class Password(activity: ContinuationContainerActivity) : SshAuthMethod(activity) - class SshKey(activity: ContinuationContainerActivity) : SshAuthMethod(activity) - class OpenKeychain(activity: ContinuationContainerActivity) : SshAuthMethod(activity) -} - -abstract class InteractivePasswordFinder : PasswordFinder { - - private var isRetry = false - - abstract fun askForPassword(cont: Continuation, isRetry: Boolean) - - final override fun reqPassword(resource: Resource<*>?): CharArray { - val password = - runBlocking(Dispatchers.Main) { - suspendCoroutine { cont -> askForPassword(cont, isRetry) } - } - isRetry = true - return password?.toCharArray() ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER) - } - - final override fun shouldRetry(resource: Resource<*>?) = true -} - -class SshjSessionFactory(private val authMethod: SshAuthMethod, private val hostKeyFile: File) : - SshSessionFactory() { - - private var currentSession: SshjSession? = null - - override fun getSession( - uri: URIish, - credentialsProvider: CredentialsProvider?, - fs: FS?, - tms: Int - ): RemoteSession { - return currentSession - ?: SshjSession(uri, uri.user, authMethod, hostKeyFile).connect().also { - logcat { "New SSH connection created" } - currentSession = it - } - } - - fun close() { - currentSession?.close() - } -} - -private fun makeTofuHostKeyVerifier(hostKeyFile: File): HostKeyVerifier { - if (!hostKeyFile.exists()) { - return object : HostKeyVerifier { - override fun verify(hostname: String?, port: Int, key: PublicKey?): Boolean { - val digest = - runCatching { SecurityUtils.getMessageDigest("SHA-256") } - .getOrElse { e -> throw SSHRuntimeException(e) } - digest.update(PlainBuffer().putPublicKey(key).compactData) - val digestData = digest.digest() - val hostKeyEntry = "SHA256:${Base64.encodeToString(digestData, Base64.NO_WRAP)}" - logcat(SshjSessionFactory::class.java.simpleName) { - "Trusting host key on first use: $hostKeyEntry" - } - hostKeyFile.writeText(hostKeyEntry) - return true - } - - override fun findExistingAlgorithms(hostname: String?, port: Int): MutableList { - return Collections.emptyList() - } - } - } else { - val hostKeyEntry = hostKeyFile.readText() - logcat(SshjSessionFactory::class.java.simpleName) { "Pinned host key: $hostKeyEntry" } - return FingerprintVerifier.getInstance(hostKeyEntry) - } -} - -private class SshjSession( - uri: URIish, - private val username: String, - private val authMethod: SshAuthMethod, - private val hostKeyFile: File -) : RemoteSession { - - private lateinit var ssh: SSHClient - private var currentCommand: Session? = null - - private val uri = - if (uri.host.contains('@')) { - // URIish's String constructor cannot handle '@' in the user part of the URI and the URL - // constructor can't be used since Java's URL does not recognize the ssh scheme. We thus - // need to patch everything up ourselves. - logcat { "Before fixup: user=${uri.user}, host=${uri.host}" } - val userPlusHost = "${uri.user}@${uri.host}" - val realUser = userPlusHost.substringBeforeLast('@') - val realHost = userPlusHost.substringAfterLast('@') - uri.setUser(realUser).setHost(realHost).also { - logcat { "After fixup: user=${it.user}, host=${it.host}" } - } - } else { - uri - } - - fun connect(): SshjSession { - ssh = SSHClient(SshjConfig()) - ssh.addHostKeyVerifier(makeTofuHostKeyVerifier(hostKeyFile)) - ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22) - if (!ssh.isConnected) throw IOException() - val passwordAuth = AuthPassword(CredentialFinder(authMethod.activity, AuthMode.Password)) - when (authMethod) { - is SshAuthMethod.Password -> { - ssh.auth(username, passwordAuth) - } - is SshAuthMethod.SshKey -> { - val pubkeyAuth = - AuthPublickey(SshKey.provide(ssh, CredentialFinder(authMethod.activity, AuthMode.SshKey))) - ssh.auth(username, pubkeyAuth, passwordAuth) - } - is SshAuthMethod.OpenKeychain -> { - runBlocking { - OpenKeychainKeyProvider.prepareAndUse(authMethod.activity) { provider -> - val openKeychainAuth = AuthPublickey(provider) - ssh.auth(username, openKeychainAuth, passwordAuth) - } - } - } - } - return this - } - - override fun exec(commandName: String?, timeout: Int): Process { - if (currentCommand != null) { - logcat(WARN) { "Killing old command" } - disconnect() - } - val session = ssh.startSession() - currentCommand = session - return SshjProcess(session.exec(commandName), timeout.toLong()) - } - - /** - * Kills the current command if one is running and returns the session into a state where `exec` - * can be called. - * - * Note that this does *not* disconnect the session. Unfortunately, the function has to be called - * `disconnect` to override the corresponding abstract function in `RemoteSession`. - */ - override fun disconnect() { - currentCommand?.close() - currentCommand = null - } - - fun close() { - disconnect() - ssh.close() - } -} - -private class SshjProcess(private val command: Session.Command, private val timeout: Long) : - Process() { - - override fun waitFor(): Int { - command.join(timeout, TimeUnit.SECONDS) - command.close() - return exitValue() - } - - override fun destroy() = command.close() - - override fun getOutputStream(): OutputStream = command.outputStream - - override fun getErrorStream(): InputStream = command.errorStream - - override fun exitValue(): Int = command.exitStatus - - override fun getInputStream(): InputStream = command.inputStream -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt b/app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt deleted file mode 100644 index d21ee24d..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.util.proxy - -import dev.msfjarvis.aps.util.settings.GitSettings -import java.io.IOException -import java.net.Authenticator -import java.net.InetSocketAddress -import java.net.PasswordAuthentication -import java.net.Proxy -import java.net.ProxySelector -import java.net.SocketAddress -import java.net.URI -import javax.inject.Inject -import javax.inject.Singleton - -/** Utility class for [Proxy] handling. */ -@Singleton -class ProxyUtils @Inject constructor(private val gitSettings: GitSettings) { - - /** Set the default [Proxy] and [Authenticator] for the app based on user provided settings. */ - fun setDefaultProxy() { - ProxySelector.setDefault( - object : ProxySelector() { - override fun select(uri: URI?): MutableList { - val host = gitSettings.proxyHost - val port = gitSettings.proxyPort - return if (host == null || port == -1) { - mutableListOf(Proxy.NO_PROXY) - } else { - mutableListOf(Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(host, port))) - } - } - - override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) { - if (uri == null || sa == null || ioe == null) { - throw IllegalArgumentException("Arguments can't be null.") - } - } - } - ) - val user = gitSettings.proxyUsername ?: "" - val password = gitSettings.proxyPassword ?: "" - if (user.isEmpty() || password.isEmpty()) { - System.clearProperty(HTTP_PROXY_USER_PROPERTY) - System.clearProperty(HTTP_PROXY_PASSWORD_PROPERTY) - } else { - System.setProperty(HTTP_PROXY_USER_PROPERTY, user) - System.setProperty(HTTP_PROXY_PASSWORD_PROPERTY, password) - } - Authenticator.setDefault( - object : Authenticator() { - override fun getPasswordAuthentication(): PasswordAuthentication? { - return if (requestorType == RequestorType.PROXY) { - PasswordAuthentication(user, password.toCharArray()) - } else { - null - } - } - } - ) - } - - companion object { - private const val HTTP_PROXY_USER_PROPERTY = "http.proxyUser" - private const val HTTP_PROXY_PASSWORD_PROPERTY = "http.proxyPassword" - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt b/app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt deleted file mode 100644 index 34c3a989..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt +++ /dev/null @@ -1,206 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.services - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.ClipData -import android.content.Intent -import android.os.Build -import android.os.IBinder -import androidx.annotation.RequiresApi -import androidx.core.app.NotificationCompat -import androidx.core.content.getSystemService -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.util.extensions.clipboard -import dev.msfjarvis.aps.util.extensions.sharedPrefs -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.logcat - -class ClipboardService : Service() { - - private val scope = CoroutineScope(Job() + Dispatchers.Main) - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (intent != null) { - when (intent.action) { - ACTION_CLEAR -> { - clearClipboard() - stopForeground(true) - stopSelf() - return super.onStartCommand(intent, flags, startId) - } - ACTION_START -> { - val time = intent.getIntExtra(EXTRA_NOTIFICATION_TIME, 45) - - if (time == 0) { - stopSelf() - } - - createNotification(time) - scope.launch { - withContext(Dispatchers.IO) { startTimer(time) } - withContext(Dispatchers.Main) { - clearClipboard() - stopForeground(true) - stopSelf() - } - } - return START_NOT_STICKY - } - } - } - - return super.onStartCommand(intent, flags, startId) - } - - override fun onBind(intent: Intent?): IBinder? { - return null - } - - override fun onDestroy() { - scope.cancel() - super.onDestroy() - } - - private fun clearClipboard() { - val deepClear = sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false) - val clipboard = clipboard - - if (clipboard != null) { - scope.launch { - logcat { "Clearing the clipboard" } - val clip = ClipData.newPlainText("pgp_handler_result_pm", "") - clipboard.setPrimaryClip(clip) - if (deepClear) { - withContext(Dispatchers.IO) { - repeat(CLIPBOARD_CLEAR_COUNT) { - val count = (it * 500).toString() - clipboard.setPrimaryClip(ClipData.newPlainText(count, count)) - } - } - } - } - } else { - logcat { "Cannot get clipboard manager service" } - } - } - - private suspend fun startTimer(showTime: Int) { - var current = 0 - while (scope.isActive && current < showTime) { - // Block for 1s or until cancel is signalled - current++ - delay(1000) - } - } - - private fun createNotification(clearTime: Int) { - val clearTimeMs = clearTime * 1000L - val clearIntent = Intent(this, ClipboardService::class.java).apply { action = ACTION_CLEAR } - val pendingIntent = - if (Build.VERSION.SDK_INT >= 26) { - PendingIntent.getForegroundService( - this, - 0, - clearIntent, - if (Build.VERSION.SDK_INT >= 31) { - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE - } else { - PendingIntent.FLAG_UPDATE_CURRENT - } - ) - } else { - PendingIntent.getService( - this, - 0, - clearIntent, - if (Build.VERSION.SDK_INT >= 31) { - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE - } else { - PendingIntent.FLAG_UPDATE_CURRENT - }, - ) - } - val notification = - if (Build.VERSION.SDK_INT <= 23) { - createNotificationApi23(pendingIntent) - } else { - createNotificationApi24(pendingIntent, clearTimeMs) - } - - createNotificationChannel() - startForeground(1, notification) - } - - private fun createNotificationApi23(pendingIntent: PendingIntent): Notification { - return NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle(getString(R.string.app_name)) - .setContentText(getString(R.string.tap_clear_clipboard)) - .setSmallIcon(R.drawable.ic_action_secure_24dp) - .setContentIntent(pendingIntent) - .setUsesChronometer(true) - .setPriority(NotificationCompat.PRIORITY_LOW) - .build() - } - - @RequiresApi(24) - private fun createNotificationApi24( - pendingIntent: PendingIntent, - clearTimeMs: Long - ): Notification { - return NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle(getString(R.string.app_name)) - .setContentText(getString(R.string.tap_clear_clipboard)) - .setSmallIcon(R.drawable.ic_action_secure_24dp) - .setContentIntent(pendingIntent) - .setUsesChronometer(true) - .setChronometerCountDown(true) - .setShowWhen(true) - .setWhen(System.currentTimeMillis() + clearTimeMs) - .setPriority(NotificationCompat.PRIORITY_LOW) - .build() - } - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= 26) { - val serviceChannel = - NotificationChannel( - CHANNEL_ID, - getString(R.string.app_name), - NotificationManager.IMPORTANCE_LOW - ) - val manager = getSystemService() - if (manager != null) { - manager.createNotificationChannel(serviceChannel) - } else { - logcat { "Failed to create notification channel" } - } - } - } - - companion object { - - const val ACTION_START = "ACTION_START_CLIPBOARD_TIMER" - const val EXTRA_NOTIFICATION_TIME = "EXTRA_NOTIFICATION_TIME" - private const val ACTION_CLEAR = "ACTION_CLEAR_CLIPBOARD" - private const val CHANNEL_ID = "NotificationService" - // Newest Samsung phones now feature a history of up to 30 items. To err on the side of - // caution, - // push 35 fake ones. - private const val CLIPBOARD_CLEAR_COUNT = 35 - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt b/app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt deleted file mode 100644 index 3a1c69d3..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.services - -import android.content.Context -import android.os.Build -import android.os.CancellationSignal -import android.service.autofill.AutofillService -import android.service.autofill.FillCallback -import android.service.autofill.FillRequest -import android.service.autofill.FillResponse -import android.service.autofill.SaveCallback -import android.service.autofill.SaveRequest -import androidx.annotation.RequiresApi -import com.github.androidpasswordstore.autofillparser.AutofillScenario -import com.github.androidpasswordstore.autofillparser.Credentials -import com.github.androidpasswordstore.autofillparser.FillableForm -import com.github.androidpasswordstore.autofillparser.FixedSaveCallback -import com.github.androidpasswordstore.autofillparser.FormOrigin -import com.github.androidpasswordstore.autofillparser.cachePublicSuffixList -import com.github.androidpasswordstore.autofillparser.passwordValue -import com.github.androidpasswordstore.autofillparser.recoverNodes -import com.github.androidpasswordstore.autofillparser.usernameValue -import dagger.hilt.android.AndroidEntryPoint -import dev.msfjarvis.aps.BuildConfig -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity -import dev.msfjarvis.aps.util.autofill.Api30AutofillResponseBuilder -import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder -import dev.msfjarvis.aps.util.extensions.getString -import dev.msfjarvis.aps.util.extensions.hasFlag -import dev.msfjarvis.aps.util.extensions.sharedPrefs -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import javax.inject.Inject -import logcat.LogPriority.ERROR -import logcat.logcat - -@RequiresApi(26) -@AndroidEntryPoint -class OreoAutofillService : AutofillService() { - - companion object { - - // TODO: Provide a user-configurable denylist - private val DENYLISTED_PACKAGES = - listOf( - BuildConfig.APPLICATION_ID, - "android", - "com.android.settings", - "com.android.settings.intelligence", - "com.android.systemui", - "com.oneplus.applocker", - "org.sufficientlysecure.keychain", - ) - - private const val DISABLE_AUTOFILL_DURATION_MS = 1000 * 60 * 60 * 24L - } - - @Inject lateinit var api30ResponseBuilderFactory: Api30AutofillResponseBuilder.Factory - @Inject lateinit var responseBuilderFactory: AutofillResponseBuilder.Factory - - override fun onCreate() { - super.onCreate() - cachePublicSuffixList(applicationContext) - } - - override fun onFillRequest( - request: FillRequest, - cancellationSignal: CancellationSignal, - callback: FillCallback - ) { - val structure = - request.fillContexts.lastOrNull()?.structure - ?: run { - callback.onSuccess(null) - return - } - if (structure.activityComponent.packageName in DENYLISTED_PACKAGES) { - if (Build.VERSION.SDK_INT >= 28) { - callback.onSuccess( - FillResponse.Builder().run { - disableAutofill(DISABLE_AUTOFILL_DURATION_MS) - build() - } - ) - } else { - callback.onSuccess(null) - } - return - } - val formToFill = - FillableForm.parseAssistStructure( - this, - structure, - isManualRequest = request.flags hasFlag FillRequest.FLAG_MANUAL_REQUEST, - getCustomSuffixes(), - ) - ?: run { - logcat { "Form cannot be filled" } - callback.onSuccess(null) - return - } - if (Build.VERSION.SDK_INT >= 30) { - api30ResponseBuilderFactory - .create(formToFill) - .fillCredentials(this, request.inlineSuggestionsRequest, callback) - } else { - responseBuilderFactory.create(formToFill).fillCredentials(this, callback) - } - } - - override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { - // SaveCallback's behavior and feature set differs based on both target and device SDK, so - // we replace it with a wrapper that works the same in all situations. - @Suppress("NAME_SHADOWING") val callback = FixedSaveCallback(this, callback) - val structure = - request.fillContexts.lastOrNull()?.structure - ?: run { - callback.onFailure(getString(R.string.oreo_autofill_save_app_not_supported)) - return - } - val clientState = - request.clientState - ?: run { - logcat(ERROR) { "Received save request without client state" } - callback.onFailure(getString(R.string.oreo_autofill_save_internal_error)) - return - } - val scenario = - AutofillScenario.fromClientState(clientState)?.recoverNodes(structure) - ?: run { - logcat(ERROR) { "Failed to recover client state or nodes from client state" } - callback.onFailure(getString(R.string.oreo_autofill_save_internal_error)) - return - } - val formOrigin = - FormOrigin.fromBundle(clientState) - ?: run { - logcat(ERROR) { "Failed to recover form origin from client state" } - callback.onFailure(getString(R.string.oreo_autofill_save_internal_error)) - return - } - - val username = scenario.usernameValue - val password = - scenario.passwordValue - ?: run { - callback.onFailure(getString(R.string.oreo_autofill_save_passwords_dont_match)) - return - } - callback.onSuccess( - AutofillSaveActivity.makeSaveIntentSender( - this, - credentials = Credentials(username, password, null), - formOrigin = formOrigin - ) - ) - } -} - -fun Context.getDefaultUsername() = - sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) - -fun Context.getCustomSuffixes(): Sequence { - return sharedPrefs - .getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) - ?.splitToSequence('\n') - ?.filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' } - ?: emptySequence() -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt b/app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt deleted file mode 100644 index 91a7ac5f..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.util.services - -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.Service -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.os.IBinder -import androidx.core.app.NotificationCompat -import androidx.core.content.getSystemService -import androidx.documentfile.provider.DocumentFile -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.repo.PasswordRepository -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -import java.util.Calendar -import java.util.TimeZone -import logcat.logcat - -class PasswordExportService : Service() { - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (intent != null) { - when (intent.action) { - ACTION_EXPORT_PASSWORD -> { - val uri = intent.getParcelableExtra("uri") - if (uri != null) { - val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri) - - if (targetDirectory != null) { - createNotification() - exportPasswords(targetDirectory) - stopSelf() - return START_NOT_STICKY - } - } - } - } - } - return super.onStartCommand(intent, flags, startId) - } - - override fun onBind(intent: Intent?): IBinder? { - return null - } - - /** - * Exports passwords to the given directory. - * - * Recursively copies the existing password store to an external directory. - * - * @param targetDirectory directory to copy password directory to. - */ - private fun exportPasswords(targetDirectory: DocumentFile) { - - val repositoryDirectory = requireNotNull(PasswordRepository.getRepositoryDirectory()) - val sourcePassDir = DocumentFile.fromFile(repositoryDirectory) - - logcat { "Copying ${repositoryDirectory.path} to $targetDirectory" } - - val dateString = - if (Build.VERSION.SDK_INT >= 26) { - LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME) - } else { - String.format("%tFT% - if (file.isDirectory) { - // Create new directory and recurse - val newDir = targetDirectory.createDirectory(file.name!!) - copyDirToDir(file, newDir!!) - } else { - copyFileToDir(file, targetDirectory) - } - } - } - - private fun createNotification() { - createNotificationChannel() - - val notification = - NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle(getString(R.string.app_name)) - .setContentText(getString(R.string.exporting_passwords)) - .setSmallIcon(R.drawable.ic_round_import_export) - .setPriority(NotificationCompat.PRIORITY_LOW) - .build() - - startForeground(2, notification) - } - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= 26) { - val serviceChannel = - NotificationChannel( - CHANNEL_ID, - getString(R.string.app_name), - NotificationManager.IMPORTANCE_LOW - ) - val manager = getSystemService() - if (manager != null) { - manager.createNotificationChannel(serviceChannel) - } else { - logcat { "Failed to create notification channel" } - } - } - } - - companion object { - - const val ACTION_EXPORT_PASSWORD = "ACTION_EXPORT_PASSWORD" - private const val CHANNEL_ID = "NotificationService" - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt b/app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt deleted file mode 100644 index 4f5187c4..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.settings - -import android.content.SharedPreferences -import androidx.core.content.edit -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching -import dev.msfjarvis.aps.data.repo.PasswordRepository -import dev.msfjarvis.aps.injection.context.FilesDirPath -import dev.msfjarvis.aps.injection.prefs.GitPreferences -import dev.msfjarvis.aps.injection.prefs.ProxyPreferences -import dev.msfjarvis.aps.injection.prefs.SettingsPreferences -import dev.msfjarvis.aps.util.extensions.getString -import java.io.File -import javax.inject.Inject -import javax.inject.Singleton -import org.eclipse.jgit.transport.URIish - -enum class Protocol(val pref: String) { - Ssh("ssh://"), - Https("https://"), - ; - - companion object { - - private val map = values().associateBy(Protocol::pref) - fun fromString(type: String?): Protocol { - return map[type ?: return Ssh] - ?: throw IllegalArgumentException("$type is not a valid Protocol") - } - } -} - -enum class AuthMode(val pref: String) { - SshKey("ssh-key"), - Password("username/password"), - OpenKeychain("OpenKeychain"), - None("None"), - ; - - companion object { - - private val map = values().associateBy(AuthMode::pref) - fun fromString(type: String?): AuthMode { - return map[type ?: return SshKey] - ?: throw IllegalArgumentException("$type is not a valid AuthMode") - } - } -} - -@Singleton -class GitSettings -@Inject -constructor( - @SettingsPreferences private val settings: SharedPreferences, - @GitPreferences private val encryptedSettings: SharedPreferences, - @ProxyPreferences private val proxySettings: SharedPreferences, - @FilesDirPath private val filesDirPath: String, -) { - - private val hostKeyPath = "$filesDirPath/.host_key" - var authMode - get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH)) - private set(value) { - settings.edit { putString(PreferenceKeys.GIT_REMOTE_AUTH, value.pref) } - } - var url - get() = settings.getString(PreferenceKeys.GIT_REMOTE_URL) - private set(value) { - require(value != null) - if (value == url) return - settings.edit { putString(PreferenceKeys.GIT_REMOTE_URL, value) } - if (PasswordRepository.isInitialized) PasswordRepository.addRemote("origin", value, true) - // When the server changes, remote password, multiplexing support and host key file - // should be deleted/reset. - useMultiplexing = true - encryptedSettings.edit { remove(PreferenceKeys.HTTPS_PASSWORD) } - clearSavedHostKey() - } - var authorName - get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME) ?: "" - set(value) { - settings.edit { putString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME, value) } - } - var authorEmail - get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL) ?: "" - set(value) { - settings.edit { putString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, value) } - } - var branch - get() = settings.getString(PreferenceKeys.GIT_BRANCH_NAME) ?: DEFAULT_BRANCH - private set(value) { - settings.edit { putString(PreferenceKeys.GIT_BRANCH_NAME, value) } - } - var useMultiplexing - get() = settings.getBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, true) - set(value) { - settings.edit { putBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, value) } - } - var proxyHost - get() = proxySettings.getString(PreferenceKeys.PROXY_HOST) - set(value) { - proxySettings.edit { putString(PreferenceKeys.PROXY_HOST, value) } - } - var proxyPort - get() = proxySettings.getInt(PreferenceKeys.PROXY_PORT, -1) - set(value) { - proxySettings.edit { putInt(PreferenceKeys.PROXY_PORT, value) } - } - var proxyUsername - get() = settings.getString(PreferenceKeys.PROXY_USERNAME) - set(value) { - proxySettings.edit { putString(PreferenceKeys.PROXY_USERNAME, value) } - } - var proxyPassword - get() = proxySettings.getString(PreferenceKeys.PROXY_PASSWORD) - set(value) { - proxySettings.edit { putString(PreferenceKeys.PROXY_PASSWORD, value) } - } - var rebaseOnPull - get() = settings.getBoolean(PreferenceKeys.REBASE_ON_PULL, true) - set(value) { - settings.edit { putBoolean(PreferenceKeys.REBASE_ON_PULL, value) } - } - - sealed class UpdateConnectionSettingsResult { - class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult() - class AuthModeMismatch(val newProtocol: Protocol, val validModes: List) : - UpdateConnectionSettingsResult() - object Valid : UpdateConnectionSettingsResult() - object FailedToParseUrl : UpdateConnectionSettingsResult() - } - - fun updateConnectionSettingsIfValid( - newAuthMode: AuthMode, - newUrl: String, - newBranch: String - ): UpdateConnectionSettingsResult { - val parsedUrl = - runCatching { URIish(newUrl) } - .getOrElse { - return UpdateConnectionSettingsResult.FailedToParseUrl - } - val newProtocol = - when (parsedUrl.scheme) { - in listOf("http", "https") -> Protocol.Https - in listOf("ssh", null) -> Protocol.Ssh - else -> return UpdateConnectionSettingsResult.FailedToParseUrl - } - if ( - (newAuthMode != AuthMode.None && newProtocol != Protocol.Https) && - parsedUrl.user.isNullOrBlank() - ) - return UpdateConnectionSettingsResult.MissingUsername(newProtocol) - val validHttpsAuth = listOf(AuthMode.None, AuthMode.Password) - val validSshAuth = listOf(AuthMode.OpenKeychain, AuthMode.Password, AuthMode.SshKey) - when { - newProtocol == Protocol.Https && newAuthMode !in validHttpsAuth -> { - return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validHttpsAuth) - } - newProtocol == Protocol.Ssh && newAuthMode !in validSshAuth -> { - return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validSshAuth) - } - } - - url = newUrl - authMode = newAuthMode - branch = newBranch - return UpdateConnectionSettingsResult.Valid - } - - /** Deletes a previously saved SSH host key */ - fun clearSavedHostKey() { - File(hostKeyPath).delete() - } - - /** Returns true if a host key was previously saved */ - fun hasSavedHostKey(): Boolean = File(hostKeyPath).exists() - - companion object { - private const val DEFAULT_BRANCH = "master" - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt b/app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt deleted file mode 100644 index 7e91ab41..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -@file:Suppress("DEPRECATION") - -package dev.msfjarvis.aps.util.settings - -import android.content.SharedPreferences -import androidx.core.content.edit -import com.github.michaelbull.result.get -import com.github.michaelbull.result.runCatching -import dev.msfjarvis.aps.util.extensions.getString -import dev.msfjarvis.aps.util.git.sshj.SshKey -import java.io.File -import java.net.URI -import logcat.LogPriority.ERROR -import logcat.LogPriority.INFO -import logcat.logcat - -private const val TAG = "Migrations" - -fun runMigrations(filesDirPath: String, sharedPrefs: SharedPreferences, gitSettings: GitSettings) { - migrateToGitUrlBasedConfig(sharedPrefs, gitSettings) - migrateToHideAll(sharedPrefs) - migrateToSshKey(filesDirPath, sharedPrefs) - migrateToClipboardHistory(sharedPrefs) - migrateToDiceware(sharedPrefs) - removeExternalStorageProperties(sharedPrefs) -} - -private fun migrateToGitUrlBasedConfig(sharedPrefs: SharedPreferences, gitSettings: GitSettings) { - val serverHostname = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_SERVER) ?: return - logcat(TAG, INFO) { "Migrating to URL-based Git config" } - val serverPort = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PORT) ?: "" - val serverUser = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_USERNAME) ?: "" - val serverPath = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_LOCATION) ?: "" - val protocol = Protocol.fromString(sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PROTOCOL)) - - // Whether we need the leading ssh:// depends on the use of a custom port. - val hostnamePart = serverHostname.removePrefix("ssh://") - val url = - when (protocol) { - Protocol.Ssh -> { - val userPart = if (serverUser.isEmpty()) "" else "${serverUser.trimEnd('@')}@" - val portPart = if (serverPort == "22" || serverPort.isEmpty()) "" else ":$serverPort" - if (portPart.isEmpty()) { - "$userPart$hostnamePart:$serverPath" - } else { - // Only absolute paths are supported with custom ports. - if (!serverPath.startsWith('/')) { - null - } else { - // We have to specify the ssh scheme as this is the only way to pass a custom - // port. - "ssh://$userPart$hostnamePart$portPart$serverPath" - } - } - } - Protocol.Https -> { - val portPart = if (serverPort == "443" || serverPort.isEmpty()) "" else ":$serverPort" - val pathPart = serverPath.trimStart('/', ':') - val urlWithFreeEntryScheme = "$hostnamePart$portPart/$pathPart" - val url = - when { - urlWithFreeEntryScheme.startsWith("https://") -> urlWithFreeEntryScheme - urlWithFreeEntryScheme.startsWith("http://") -> - urlWithFreeEntryScheme.replaceFirst("http", "https") - else -> "https://$urlWithFreeEntryScheme" - } - runCatching { if (URI(url).rawAuthority != null) url else null }.get() - } - } - - sharedPrefs.edit { - remove(PreferenceKeys.GIT_REMOTE_LOCATION) - remove(PreferenceKeys.GIT_REMOTE_PORT) - remove(PreferenceKeys.GIT_REMOTE_SERVER) - remove(PreferenceKeys.GIT_REMOTE_USERNAME) - remove(PreferenceKeys.GIT_REMOTE_PROTOCOL) - } - if ( - url == null || - gitSettings.updateConnectionSettingsIfValid( - newAuthMode = gitSettings.authMode, - newUrl = url, - newBranch = gitSettings.branch - ) != GitSettings.UpdateConnectionSettingsResult.Valid - ) { - logcat(TAG, ERROR) { "Failed to migrate to URL-based Git config, generated URL is invalid" } - } -} - -private fun migrateToHideAll(sharedPrefs: SharedPreferences) { - sharedPrefs.all[PreferenceKeys.SHOW_HIDDEN_FOLDERS] ?: return - val isHidden = sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false) - sharedPrefs.edit { - remove(PreferenceKeys.SHOW_HIDDEN_FOLDERS) - putBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, isHidden) - } -} - -private fun migrateToSshKey(filesDirPath: String, sharedPrefs: SharedPreferences) { - val privateKeyFile = File(filesDirPath, ".ssh_key") - if ( - sharedPrefs.contains(PreferenceKeys.USE_GENERATED_KEY) && - !SshKey.exists && - privateKeyFile.exists() - ) { - // Currently uses a private key imported or generated with an old version of Password Store. - // Generated keys come with a public key which the user should still be able to view after - // the migration (not possible for regular imported keys), hence the special case. - val isGeneratedKey = sharedPrefs.getBoolean(PreferenceKeys.USE_GENERATED_KEY, false) - SshKey.useLegacyKey(isGeneratedKey) - sharedPrefs.edit { remove(PreferenceKeys.USE_GENERATED_KEY) } - } -} - -private fun migrateToClipboardHistory(sharedPrefs: SharedPreferences) { - if (sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X)) { - sharedPrefs.edit { - putBoolean( - PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, - sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, false) - ) - remove(PreferenceKeys.CLEAR_CLIPBOARD_20X) - } - } -} - -private fun migrateToDiceware(sharedPrefs: SharedPreferences) { - if (sharedPrefs.contains(PreferenceKeys.PREF_KEY_PWGEN_TYPE)) { - sharedPrefs.edit { - if (sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd") { - putString(PreferenceKeys.PREF_KEY_PWGEN_TYPE, "diceware") - } - } - } -} - -private fun removeExternalStorageProperties(prefs: SharedPreferences) { - logcat(TAG, INFO) { "Removing preferences related to external storage" } - prefs.edit { - if (prefs.contains(PreferenceKeys.GIT_EXTERNAL)) { - if (prefs.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) { - putBoolean(PreferenceKeys.GIT_EXTERNAL_MIGRATED, true) - } - remove(PreferenceKeys.GIT_EXTERNAL) - } - if (prefs.contains(PreferenceKeys.GIT_EXTERNAL_REPO)) { - remove(PreferenceKeys.GIT_EXTERNAL_REPO) - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt b/app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt deleted file mode 100644 index 6b48b6a9..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.util.settings - -import android.content.Context -import android.content.SharedPreferences -import dev.msfjarvis.aps.Application -import dev.msfjarvis.aps.data.password.PasswordItem -import dev.msfjarvis.aps.util.extensions.base64 -import dev.msfjarvis.aps.util.extensions.getString - -enum class PasswordSortOrder(val comparator: java.util.Comparator) { - FOLDER_FIRST( - Comparator { p1: PasswordItem, p2: PasswordItem -> - (p1.type + p1.name).compareTo(p2.type + p2.name, ignoreCase = true) - } - ), - INDEPENDENT( - Comparator { p1: PasswordItem, p2: PasswordItem -> - p1.name.compareTo(p2.name, ignoreCase = true) - } - ), - RECENTLY_USED( - Comparator { p1: PasswordItem, p2: PasswordItem -> - val recentHistory = - Application.instance.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) - val timeP1 = recentHistory.getString(p1.file.absolutePath.base64()) - val timeP2 = recentHistory.getString(p2.file.absolutePath.base64()) - when { - timeP1 != null && timeP2 != null -> timeP2.compareTo(timeP1) - timeP1 != null && timeP2 == null -> return@Comparator -1 - timeP1 == null && timeP2 != null -> return@Comparator 1 - else -> p1.name.compareTo(p2.name, ignoreCase = true) - } - } - ), - FILE_FIRST( - Comparator { p1: PasswordItem, p2: PasswordItem -> - (p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true) - } - ); - - companion object { - - @JvmStatic - fun getSortOrder(settings: SharedPreferences): PasswordSortOrder { - return valueOf(settings.getString(PreferenceKeys.SORT_ORDER) ?: FOLDER_FIRST.name) - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt b/app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt deleted file mode 100644 index 7f4e0daf..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.util.settings - -object PreferenceKeys { - - const val APP_THEME = "app_theme" - const val AUTOFILL_ENABLE = "autofill_enable" - const val BIOMETRIC_AUTH = "biometric_auth" - @Deprecated( - message = "Use CLEAR_CLIPBOARD_HISTORY instead", - replaceWith = ReplaceWith("PreferenceKeys.CLEAR_CLIPBOARD_HISTORY"), - ) - const val CLEAR_CLIPBOARD_20X = "clear_clipboard_20x" - const val CLEAR_CLIPBOARD_HISTORY = "clear_clipboard_history" - const val CLEAR_SAVED_PASS = "clear_saved_pass" - const val COPY_ON_DECRYPT = "copy_on_decrypt" - const val ENABLE_DEBUG_LOGGING = "enable_debug_logging" - const val EXPORT_PASSWORDS = "export_passwords" - const val FILTER_RECURSIVELY = "filter_recursively" - const val GENERAL_SHOW_TIME = "general_show_time" - const val GIT_CONFIG = "git_config" - const val GIT_CONFIG_AUTHOR_EMAIL = "git_config_user_email" - const val GIT_CONFIG_AUTHOR_NAME = "git_config_user_name" - @Deprecated(message = "We're removing support for external storage") - const val GIT_EXTERNAL = "git_external" - @Deprecated(message = "We're removing support for external storage") - const val GIT_EXTERNAL_REPO = "git_external_repo" - const val GIT_EXTERNAL_MIGRATED = "git_external_migrated" - const val GIT_REMOTE_AUTH = "git_remote_auth" - const val GIT_REMOTE_KEY_TYPE = "git_remote_key_type" - - @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_LOCATION = "git_remote_location" - const val GIT_REMOTE_USE_MULTIPLEXING = "git_remote_use_multiplexing" - - @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_PORT = "git_remote_port" - - @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_PROTOCOL = "git_remote_protocol" - const val GIT_DELETE_REPO = "git_delete_repo" - - @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_SERVER = "git_remote_server" - const val GIT_REMOTE_URL = "git_remote_url" - - @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_USERNAME = "git_remote_username" - const val GIT_SERVER_INFO = "git_server_info" - const val GIT_BRANCH_NAME = "git_branch" - const val HTTPS_PASSWORD = "https_password" - const val LENGTH = "length" - const val OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES = "oreo_autofill_custom_public_suffixes" - const val OREO_AUTOFILL_DEFAULT_USERNAME = "oreo_autofill_default_username" - const val OREO_AUTOFILL_DIRECTORY_STRUCTURE = "oreo_autofill_directory_structure" - const val PREF_KEY_PWGEN_TYPE = "pref_key_pwgen_type" - const val REPOSITORY_INITIALIZED = "repository_initialized" - const val REPO_CHANGED = "repo_changed" - const val SEARCH_ON_START = "search_on_start" - - @Deprecated( - message = "Use SHOW_HIDDEN_CONTENTS instead", - replaceWith = ReplaceWith("PreferenceKeys.SHOW_HIDDEN_CONTENTS") - ) - const val SHOW_HIDDEN_FOLDERS = "show_hidden_folders" - const val SHOW_HIDDEN_CONTENTS = "show_hidden_contents" - const val SORT_ORDER = "sort_order" - const val SHOW_PASSWORD = "show_password" - const val SSH_KEY = "ssh_key" - const val SSH_KEYGEN = "ssh_keygen" - const val SSH_KEY_LOCAL_PASSPHRASE = "ssh_key_local_passphrase" - const val SSH_OPENKEYSTORE_CLEAR_KEY_ID = "ssh_openkeystore_clear_keyid" - const val SSH_OPENKEYSTORE_KEYID = "ssh_openkeystore_keyid" - const val SSH_SEE_KEY = "ssh_see_key" - - @Deprecated("To be used only in Migrations.kt") const val USE_GENERATED_KEY = "use_generated_key" - - const val PROXY_SETTINGS = "proxy_settings" - const val PROXY_HOST = "proxy_host" - const val PROXY_PORT = "proxy_port" - const val PROXY_USERNAME = "proxy_username" - const val PROXY_PASSWORD = "proxy_password" - - const val REBASE_ON_PULL = "rebase_on_pull" - - const val DICEWARE_SEPARATOR = "diceware_separator" - const val DICEWARE_LENGTH = "diceware_length" - const val DISABLE_SYNC_ACTION = "disable_sync_action" -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/shortcuts/ShortcutHandler.kt b/app/src/main/java/dev/msfjarvis/aps/util/shortcuts/ShortcutHandler.kt deleted file mode 100644 index 2ca881ed..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/shortcuts/ShortcutHandler.kt +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.util.shortcuts - -import android.content.Context -import android.content.Intent -import android.content.pm.ShortcutInfo -import android.content.pm.ShortcutManager -import android.graphics.drawable.Icon -import android.os.Build -import androidx.annotation.RequiresApi -import androidx.core.content.getSystemService -import dagger.Reusable -import dagger.hilt.android.qualifiers.ApplicationContext -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.password.PasswordItem -import javax.inject.Inject -import logcat.logcat - -@Reusable -class ShortcutHandler -@Inject -constructor( - @ApplicationContext val context: Context, -) { - - private companion object { - - // The max shortcut count from the system is set to 15 for some godforsaken reason, which - // makes zero sense and is why our update logic just never worked. Capping it at 4 which is - // what most launchers seem to have agreed upon is the only reasonable solution. - private const val MAX_SHORTCUT_COUNT = 4 - } - - /** - * Creates a - * [dynamic shortcut](https://developer.android.com/guide/topics/ui/shortcuts/creating-shortcuts#dynamic) - * that shows up with the app icon on long press. The list of items is capped to - * [MAX_SHORTCUT_COUNT] and older items are removed by a simple LRU sweep. - */ - fun addDynamicShortcut(item: PasswordItem, intent: Intent) { - if (Build.VERSION.SDK_INT < 25) return - val shortcutManager: ShortcutManager = context.getSystemService() ?: return - val shortcut = buildShortcut(item, intent) - val shortcuts = shortcutManager.dynamicShortcuts - // If we're above or equal to the maximum shortcuts allowed, drop the last item. - if (shortcuts.size >= MAX_SHORTCUT_COUNT) { - shortcuts.removeLast() - } - // Reverse the list so we can append our new shortcut at the 'end'. - shortcuts.reverse() - shortcuts.add(shortcut) - // Reverse it again, so the previous items are now in the correct order and our new item - // is at the front like it's supposed to. - shortcuts.reverse() - // Write back the new shortcuts. - shortcutManager.dynamicShortcuts = shortcuts.map(::rebuildShortcut) - } - - /** - * Creates a - * [pinned shortcut](https://developer.android.com/guide/topics/ui/shortcuts/creating-shortcuts#pinned) - * which presents a UI to users, allowing manual placement on the launcher screen. This method is - * a no-op if the user's default launcher does not support pinned shortcuts. - */ - fun addPinnedShortcut(item: PasswordItem, intent: Intent) { - if (Build.VERSION.SDK_INT < 26) return - val shortcutManager: ShortcutManager = context.getSystemService() ?: return - if (!shortcutManager.isRequestPinShortcutSupported) { - logcat { "addPinnedShortcut: pin shortcuts unsupported" } - return - } - val shortcut = buildShortcut(item, intent) - shortcutManager.requestPinShortcut(shortcut, null) - } - - /** Creates a [ShortcutInfo] from [item] and assigns [intent] to it. */ - @RequiresApi(25) - private fun buildShortcut(item: PasswordItem, intent: Intent): ShortcutInfo { - return ShortcutInfo.Builder(context, item.fullPathToParent) - .setShortLabel(item.toString()) - .setLongLabel(item.fullPathToParent + item.toString()) - .setIcon(Icon.createWithResource(context, R.drawable.ic_lock_open_24px)) - .setIntent(intent) - .build() - } - - /** - * Takes an existing [ShortcutInfo] and builds a fresh instance of [ShortcutInfo] with the same - * data, which ensures that the get/set dance in [addDynamicShortcut] does not cause invalidation - * of icon assets, resulting in invisible icons in all but the newest launcher shortcut. - */ - @RequiresApi(25) - private fun rebuildShortcut(shortcut: ShortcutInfo): ShortcutInfo { - // Non-null assertions are fine since we know these values aren't null. - return ShortcutInfo.Builder(context, shortcut.id) - .setShortLabel(shortcut.shortLabel!!) - .setLongLabel(shortcut.longLabel!!) - .setIcon(Icon.createWithResource(context, R.drawable.ic_lock_open_24px)) - .setIntent(shortcut.intent!!) - .build() - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt b/app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt deleted file mode 100644 index 9c4dc4f4..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.util.totp - -import android.net.Uri -import javax.inject.Inject - -/** [Uri] backed TOTP URL parser. */ -class UriTotpFinder @Inject constructor() : TotpFinder { - - override fun findSecret(content: String): String? { - content.split("\n".toRegex()).forEach { line -> - if (line.startsWith(TotpFinder.TOTP_FIELDS[0])) { - return Uri.parse(line).getQueryParameter("secret") - } - if (line.startsWith(TotpFinder.TOTP_FIELDS[1], ignoreCase = true)) { - return line.split(": *".toRegex(), 2).toTypedArray()[1] - } - } - return null - } - - override fun findDigits(content: String): String { - return getQueryParameter(content, "digits") ?: "6" - } - - override fun findPeriod(content: String): Long { - return getQueryParameter(content, "period")?.toLongOrNull() ?: 30 - } - - override fun findAlgorithm(content: String): String { - return getQueryParameter(content, "algorithm") ?: "sha1" - } - - override fun findIssuer(content: String): String? { - return getQueryParameter(content, "issuer") ?: Uri.parse(content).authority - } - - private fun getQueryParameter(content: String, parameterName: String): String? { - content.split("\n".toRegex()).forEach { line -> - val uri = Uri.parse(line) - if ( - line.startsWith(TotpFinder.TOTP_FIELDS[0]) && uri.getQueryParameter(parameterName) != null - ) { - return uri.getQueryParameter(parameterName) - } - } - return null - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt b/app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt deleted file mode 100644 index 5f3d04e3..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt +++ /dev/null @@ -1,473 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.viewmodel - -import android.app.Application -import android.os.Parcelable -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.annotation.VisibleForTesting -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.asFlow -import androidx.lifecycle.asLiveData -import androidx.recyclerview.selection.ItemDetailsLookup -import androidx.recyclerview.selection.ItemKeyProvider -import androidx.recyclerview.selection.Selection -import androidx.recyclerview.selection.SelectionPredicates -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.selection.StorageStrategy -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.github.androidpasswordstore.sublimefuzzy.Fuzzy -import dev.msfjarvis.aps.data.password.PasswordItem -import dev.msfjarvis.aps.data.repo.PasswordRepository -import dev.msfjarvis.aps.util.autofill.AutofillPreferences -import dev.msfjarvis.aps.util.autofill.DirectoryStructure -import dev.msfjarvis.aps.util.extensions.sharedPrefs -import dev.msfjarvis.aps.util.extensions.unsafeLazy -import dev.msfjarvis.aps.util.settings.PasswordSortOrder -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import java.io.File -import java.text.Collator -import java.util.Locale -import java.util.Stack -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.yield -import me.zhanghai.android.fastscroll.PopupTextProvider - -private fun File.toPasswordItem() = - if (isFile) PasswordItem.newPassword(name, this, PasswordRepository.getRepositoryDirectory()) - else PasswordItem.newCategory(name, this, PasswordRepository.getRepositoryDirectory()) - -private fun PasswordItem.fuzzyMatch(filter: String): Int { - val (_, score) = Fuzzy.fuzzyMatch(filter, longName) - return score -} - -private val CaseInsensitiveComparator = Collator.getInstance().apply { strength = Collator.PRIMARY } - -private fun PasswordItem.Companion.makeComparator( - typeSortOrder: PasswordSortOrder, - directoryStructure: DirectoryStructure -): Comparator { - return when (typeSortOrder) { - PasswordSortOrder.FOLDER_FIRST -> compareBy { it.type } - // In order to let INDEPENDENT not distinguish between items based on their type, we - // simply - // declare them all equal at this stage. - PasswordSortOrder.INDEPENDENT -> Comparator { _, _ -> 0 } - PasswordSortOrder.FILE_FIRST -> compareByDescending { it.type } - PasswordSortOrder.RECENTLY_USED -> PasswordSortOrder.RECENTLY_USED.comparator - } - .then( - compareBy(nullsLast(CaseInsensitiveComparator)) { - directoryStructure.getIdentifierFor(it.file) - } - ) - .then( - compareBy(nullsLast(CaseInsensitiveComparator)) { directoryStructure.getUsernameFor(it.file) } - ) -} - -val PasswordItem.stableId: String - get() = file.absolutePath - -enum class FilterMode { - NoFilter, - StrictDomain, - Fuzzy -} - -enum class SearchMode { - RecursivelyInSubdirectories, - InCurrentDirectoryOnly -} - -enum class ListMode { - FilesOnly, - DirectoriesOnly, - AllEntries -} - -@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) -class SearchableRepositoryViewModel(application: Application) : AndroidViewModel(application) { - - private var _updateCounter = 0 - private val updateCounter: Int - get() = _updateCounter - - private fun forceUpdateOnNextSearchAction() { - _updateCounter++ - } - - private val root - get() = PasswordRepository.getRepositoryDirectory() - private val settings by unsafeLazy { application.sharedPrefs } - private val showHiddenContents - get() = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false) - private val defaultSearchMode - get() = - if (settings.getBoolean(PreferenceKeys.FILTER_RECURSIVELY, true)) { - SearchMode.RecursivelyInSubdirectories - } else { - SearchMode.InCurrentDirectoryOnly - } - - private val typeSortOrder - get() = PasswordSortOrder.getSortOrder(settings) - private val directoryStructure - get() = AutofillPreferences.directoryStructure(getApplication()) - private val itemComparator - get() = PasswordItem.makeComparator(typeSortOrder, directoryStructure) - - private data class SearchAction( - val baseDirectory: File, - val filter: String, - val filterMode: FilterMode, - val searchMode: SearchMode, - val listMode: ListMode, - // This counter can be increased to force a reexecution of the search action even if all - // other arguments are left unchanged. - val updateCounter: Int - ) - - private fun makeSearchAction( - baseDirectory: File, - filter: String, - filterMode: FilterMode, - searchMode: SearchMode, - listMode: ListMode - ): SearchAction { - return SearchAction( - baseDirectory = baseDirectory, - filter = filter, - filterMode = filterMode, - searchMode = searchMode, - listMode = listMode, - updateCounter = updateCounter - ) - } - - private fun updateSearchAction(action: SearchAction) = action.copy(updateCounter = updateCounter) - - private val searchAction = - MutableLiveData( - makeSearchAction( - baseDirectory = root, - filter = "", - filterMode = FilterMode.NoFilter, - searchMode = SearchMode.InCurrentDirectoryOnly, - listMode = ListMode.AllEntries - ) - ) - private val searchActionFlow = searchAction.asFlow().distinctUntilChanged() - - data class SearchResult(val passwordItems: List, val isFiltered: Boolean) - - val searchResult = - searchActionFlow - .mapLatest { searchAction -> - val listResultFlow = - when (searchAction.searchMode) { - SearchMode.RecursivelyInSubdirectories -> - listFilesRecursively(searchAction.baseDirectory) - SearchMode.InCurrentDirectoryOnly -> listFiles(searchAction.baseDirectory) - } - val prefilteredResultFlow = - when (searchAction.listMode) { - ListMode.FilesOnly -> listResultFlow.filter { it.isFile } - ListMode.DirectoriesOnly -> listResultFlow.filter { it.isDirectory } - ListMode.AllEntries -> listResultFlow - } - val passwordList = - when (if (searchAction.filter == "") FilterMode.NoFilter else searchAction.filterMode) { - FilterMode.NoFilter -> { - prefilteredResultFlow.map { it.toPasswordItem() }.toList().sortedWith(itemComparator) - } - FilterMode.StrictDomain -> { - check(searchAction.listMode == ListMode.FilesOnly) { - "Searches with StrictDomain search mode can only list files" - } - val regex = generateStrictDomainRegex(searchAction.filter) - if (regex != null) { - prefilteredResultFlow - .filter { absoluteFile -> - regex.containsMatchIn(absoluteFile.relativeTo(root).path) - } - .map { it.toPasswordItem() } - .toList() - .sortedWith(itemComparator) - } else { - emptyList() - } - } - FilterMode.Fuzzy -> { - prefilteredResultFlow - .map { - val item = it.toPasswordItem() - Pair(item.fuzzyMatch(searchAction.filter), item) - } - .filter { it.first > 0 } - .toList() - .sortedWith( - compareByDescending> { it.first } - .thenBy(itemComparator) { it.second } - ) - .map { it.second } - } - } - SearchResult(passwordList, isFiltered = searchAction.filterMode != FilterMode.NoFilter) - } - .asLiveData(Dispatchers.IO) - - private fun shouldTake(file: File) = - with(file) { - if (showHiddenContents) { - return !file.name.startsWith(".git") - } - if (isDirectory) { - !isHidden - } else { - !isHidden && file.extension == "gpg" - } - } - - private fun listFiles(dir: File): Flow { - return dir.listFiles(::shouldTake)?.asFlow() ?: emptyFlow() - } - - private fun listFilesRecursively(dir: File): Flow { - return dir - // Take top directory even if it is hidden. - .walkTopDown() - .onEnter { file -> file == dir || shouldTake(file) } - .asFlow() - // Skip the root directory - .drop(1) - .map { - yield() - it - } - .filter(::shouldTake) - } - - private val _currentDir = MutableLiveData(root) - val currentDir = _currentDir as LiveData - - data class NavigationStackEntry(val dir: File, val recyclerViewState: Parcelable?) - - private val navigationStack = Stack() - - fun navigateTo( - newDirectory: File = root, - listMode: ListMode = ListMode.AllEntries, - recyclerViewState: Parcelable? = null, - pushPreviousLocation: Boolean = true - ) { - if (!newDirectory.exists()) return - require(newDirectory.isDirectory) { "Can only navigate to a directory" } - if (pushPreviousLocation) { - navigationStack.push(NavigationStackEntry(_currentDir.value!!, recyclerViewState)) - } - searchAction.postValue( - makeSearchAction( - filter = "", - baseDirectory = newDirectory, - filterMode = FilterMode.NoFilter, - searchMode = SearchMode.InCurrentDirectoryOnly, - listMode = listMode - ) - ) - _currentDir.postValue(newDirectory) - } - - val canNavigateBack - get() = navigationStack.isNotEmpty() - - /** - * Navigate back to the last location on the [navigationStack] and restore a cached scroll - * position if possible. - * - * Returns the old RecyclerView's LinearLayoutManager state as a [Parcelable] if it was cached. - */ - fun navigateBack(): Parcelable? { - if (!canNavigateBack) return null - val (oldDir, oldRecyclerViewState) = navigationStack.pop() - navigateTo(oldDir, pushPreviousLocation = false) - return oldRecyclerViewState - } - - fun reset() { - navigationStack.clear() - forceUpdateOnNextSearchAction() - navigateTo(pushPreviousLocation = false) - } - - fun search( - filter: String, - baseDirectory: File? = null, - filterMode: FilterMode = FilterMode.Fuzzy, - searchMode: SearchMode? = null, - listMode: ListMode = ListMode.AllEntries - ) { - require(baseDirectory?.isDirectory != false) { "Can only search in a directory" } - searchAction.postValue( - makeSearchAction( - filter = filter, - baseDirectory = baseDirectory ?: _currentDir.value!!, - filterMode = filterMode, - searchMode = searchMode ?: defaultSearchMode, - listMode = listMode - ) - ) - } - - fun forceRefresh() { - forceUpdateOnNextSearchAction() - searchAction.postValue(updateSearchAction(searchAction.value!!)) - } - - companion object { - - @VisibleForTesting - fun generateStrictDomainRegex(domain: String): Regex? { - // Valid domains do not contain path separators. - if (domain.contains('/')) return null - // Matches the start of a path component, which is either the start of the - // string or a path separator. - val prefix = """(?:^|/)""" - val escapedFilter = Regex.escape(domain.replace("/", "")) - // Matches either the filter literally or a strict subdomain of the filter term. - // We allow a lot of freedom in what a subdomain is, as long as it is not an - // email address. - val subdomain = """(?:(?:[^/@]+\.)?$escapedFilter)""" - // Matches the end of a path component, which is either the literal ".gpg" or a - // path separator. - val suffix = """(?:\.gpg|/)""" - // Match any relative path with a component that is a subdomain of the filter. - return Regex(prefix + subdomain + suffix) - } - } -} - -private object PasswordItemDiffCallback : DiffUtil.ItemCallback() { - - override fun areItemsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = - oldItem.file.absolutePath == newItem.file.absolutePath - - override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = oldItem == newItem -} - -open class SearchableRepositoryAdapter( - private val layoutRes: Int, - private val viewHolderCreator: (view: View) -> T, - private val coroutineScope: CoroutineScope, - private val viewHolderBinder: suspend T.(item: PasswordItem) -> Unit, -) : ListAdapter(PasswordItemDiffCallback), PopupTextProvider { - - fun > makeSelectable( - recyclerView: RecyclerView, - itemDetailsLookupCreator: (recyclerView: RecyclerView) -> T - ) { - selectionTracker = - SelectionTracker.Builder( - "SearchableRepositoryAdapter", - recyclerView, - itemKeyProvider, - itemDetailsLookupCreator(recyclerView), - StorageStrategy.createStringStorage() - ) - .withSelectionPredicate(SelectionPredicates.createSelectAnything()) - .build() - .apply { - addObserver( - object : SelectionTracker.SelectionObserver() { - override fun onSelectionChanged() { - this@SearchableRepositoryAdapter.onSelectionChangedListener?.invoke( - requireSelectionTracker().selection - ) - } - } - ) - } - } - - private var onItemClickedListener: ((holder: T, item: PasswordItem) -> Unit)? = null - open fun onItemClicked( - listener: (holder: T, item: PasswordItem) -> Unit - ): SearchableRepositoryAdapter { - check(onItemClickedListener == null) { - "Only a single listener can be registered for onItemClicked" - } - onItemClickedListener = listener - return this - } - - private var onSelectionChangedListener: ((selection: Selection) -> Unit)? = null - open fun onSelectionChanged( - listener: (selection: Selection) -> Unit - ): SearchableRepositoryAdapter { - check(onSelectionChangedListener == null) { - "Only a single listener can be registered for onSelectionChanged" - } - onSelectionChangedListener = listener - return this - } - - private val itemKeyProvider = - object : ItemKeyProvider(SCOPE_MAPPED) { - override fun getKey(position: Int) = getItem(position).stableId - - override fun getPosition(key: String) = - (0 until itemCount).firstOrNull { getItem(it).stableId == key } ?: RecyclerView.NO_POSITION - } - - private var selectionTracker: SelectionTracker? = null - fun requireSelectionTracker() = selectionTracker!! - - private val selectedFiles - get() = requireSelectionTracker().selection.map { File(it) } - - fun getSelectedItems() = selectedFiles.map { it.toPasswordItem() } - - fun getPositionForFile(file: File) = itemKeyProvider.getPosition(file.absolutePath) - - final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T { - val view = LayoutInflater.from(parent.context).inflate(layoutRes, parent, false) - return viewHolderCreator(view) - } - - final override fun onBindViewHolder(holder: T, position: Int) { - val item = getItem(position) - holder.apply { - coroutineScope.launch(Dispatchers.Main.immediate) { viewHolderBinder.invoke(holder, item) } - selectionTracker?.let { itemView.isSelected = it.isSelected(item.stableId) } - itemView.setOnClickListener { - // Do not emit custom click events while the user is selecting items. - if (selectionTracker?.hasSelection() != true) onItemClickedListener?.invoke(holder, item) - } - } - } - final override fun getPopupText(position: Int): String { - return getItem(position).name[0].toString().uppercase(Locale.getDefault()) - } -} -- cgit v1.2.3